diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35eee3420a..d3127ea371 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -586,6 +586,7 @@ jobs: env: OPENCODE_VERSION: 0.0.0-sanity-${{ github.sha }} OPENCODE_RELEASE: "1" + ALTIMATE_BASE_GATEWAY_URL: https://gateway.test MODELS_DEV_API_JSON: test/tool/fixtures/models-api.json - name: Build dbt-tools diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d65e23a06..21a1ae9062 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,6 +97,8 @@ jobs: # a plain tag (v0.9.0) goes to `latest`. Prevents a beta tag from bricking the whole user base. OPENCODE_CHANNEL: ${{ contains(github.ref_name, '-') && 'beta' || 'latest' }} OPENCODE_RELEASE: "1" + # altimate_change — embed the operator-controlled Base endpoint without publishing it in source + ALTIMATE_BASE_GATEWAY_URL: ${{ vars.ALTIMATE_BASE_GATEWAY_URL }} GH_REPO: ${{ env.GH_REPO }} # altimate_change — MODELS_DEV_API_JSON is deliberately NOT set here. # Pointing it at test/tool/fixtures/models-api.json (as ci.yml does, where diff --git a/README.md b/README.md index 7650ace3fd..452a2bb577 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,16 @@ installing it in your own repository. Then — in order: -**Step 1: Configure your LLM provider** (required before anything works): +**Step 1: Choose an LLM provider** (required before anything works): ```bash altimate # Launch the TUI -/connect # Interactive setup — choose your provider and enter your API key +/connect # Interactive setup — choose Altimate Base, sign in, or bring an API key ``` +Altimate Base is the free, no-signup option. It is rate limited, and its requests and responses +are logged and may be used to improve Altimate products and services; do not send secrets or +confidential code. The setup dialog shows this disclosure and defaults to **No** before registering. + Or set an environment variable directly: ```bash export ANTHROPIC_API_KEY=your_key # Anthropic Claude diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index d07992a929..a69dc1e66c 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -46,6 +46,54 @@ For pricing, security, and data handling details, see the [Altimate LLM Gateway !!! tip "Automatic model selection" When Altimate credentials are configured and no model is explicitly chosen, the Altimate LLM Gateway is selected automatically. You can override this by setting `model` in your config or by restricting the `provider` section to specific providers only. +## Altimate Base + +Altimate Base is Altimate's own hosted free model. It requires no signup or user-managed API key +and is subject to rate limits and abuse protection. + +**Data handling:** Requests and responses are logged and may be used to improve Altimate's products, +including the model. Secrets are automatically masked before storage, but don't rely on it — avoid +sending secrets or confidential code. Altimate Base is pseudonymous, not anonymous: a stable +per-installation identifier links your requests across launches and `altimate providers logout +altimate-base` does not reset it (see the [security FAQ](../reference/security-faq.md)). Usage is +rate limited. + +If you need stronger guarantees — no training on your data, metadata-only retention — use the +[Altimate LLM Gateway](https://help.altimate.ai/datamates/user-guide/components/llm-gateway/) +instead. + +Choose **Altimate Base** from the first-run picker or `/connect`. A disclosure is shown before any +registration request; **No** is selected by default. After registration, the model is available as +`altimate-free/altimate-base` and becomes the free fallback when no paid Altimate Gateway or +explicit model is selected. Big Pickle is retired as a new selection — it no longer appears in the +picker or the full model catalog for users choosing a model for the first time. Users already on +Big Pickle are still detected on launch and offered Altimate Base through the same consent gate. + +Official release binaries embed the current gateway endpoint at build time. Operators and local +development can override it without changing code: + +```bash +export ALTIMATE_BASE_GATEWAY_URL=https://your-gateway.example +altimate +``` + +The URL must use HTTPS. Credentials, +query strings, and fragments in the URL are rejected. `ALTIMATE_FREE_GATEWAY_URL` is retained as a +legacy fallback, but `ALTIMATE_BASE_GATEWAY_URL` takes precedence. If the configured gateway host +changes, credentials issued by the previous host are not loaded and the consented registration +flow must run again. + +Altimate Base credentials are stored separately from the shared provider-auth file and are never +returned to the TUI. The installation secret is hashed before registration; the gateway receives +the hash, not the local secret. + +That hash is stable across launches, so it links this installation's logged requests together — +it is what enforces the free allowance. Running `altimate providers logout altimate-base` clears +the credential but keeps the installation identity on purpose, so logging out is not a way to +reset the allowance. Each inference request additionally carries a session identifier used for +rate limiting. See the security FAQ for what this means for privacy and how to reset the local +identity. + ## Anthropic ```json diff --git a/docs/docs/getting-started/quickstart.md b/docs/docs/getting-started/quickstart.md index e7ec4bfd48..fafca014e6 100644 --- a/docs/docs/getting-started/quickstart.md +++ b/docs/docs/getting-started/quickstart.md @@ -25,7 +25,7 @@ On a fresh install, a welcome panel appears with a curated 6-provider picker: - **Altimate LLM Gateway** *(recommended)* — 10M tokens free, no API keys. Routes to the best model per task across Sonnet, Opus, GPT-5, and more. Sign-in opens a browser tab; complete Google or email signup and you're back in the TUI. If your terminal can't open a browser (SSH / tmux / WSL), the CLI prints the URL — paste it into a browser on your desktop. - **Anthropic** / **OpenAI** / **Google** — paste an API key or OAuth in. -- **Big Pickle** — free tier, chats work but many data tasks fail; useful for kicking tires. +- **Altimate Base** — a hosted open model, free and rate limited, with no signup or API key. Requests and responses may be logged and used to improve Altimate's products, so do not send secrets or confidential code. Registration happens only after an explicit confirmation that defaults to **No**. - **Search all providers…** — full picker if you need Bedrock, Databricks AI Gateway, Cloudflare AI Gateway, Snowflake Cortex, DigitalOcean Inference, etc. Or set an environment variable and skip the picker: @@ -36,7 +36,7 @@ altimate ``` !!! tip "Don't want to manage API keys?" - The [Altimate LLM Gateway](https://help.altimate.ai/datamates/user-guide/components/llm-gateway/) is the top row of the picker — 10M free tokens, and altimate-code auto-selects the right model per task. First-run sign-in uses a loopback OAuth on `127.0.0.1:7317-7325` (falls back if the preferred port is taken). + Choose **Altimate Base** for the no-signup, rate-limited model. Choose the [Altimate LLM Gateway](https://help.altimate.ai/datamates/user-guide/components/llm-gateway/) for 10M free tokens and automatic model routing. Gateway sign-in uses a loopback OAuth on `127.0.0.1:7317-7325` (falls back if the preferred port is taken). --- diff --git a/docs/docs/reference/network.md b/docs/docs/reference/network.md index 5673f5d1b9..4f1bc30588 100644 --- a/docs/docs/reference/network.md +++ b/docs/docs/reference/network.md @@ -41,6 +41,7 @@ altimate needs outbound HTTPS access to: | Destination | Purpose | |-------------|---------| | Your LLM provider API | Model inference (Anthropic, OpenAI, etc.) | +| Official Altimate Base gateway (embedded in release), or the host set by `ALTIMATE_BASE_GATEWAY_URL` | Altimate Base registration and inference when you explicitly enable Altimate Base | | `registry.npmjs.org` | Package updates | | `models.dev` | Model catalog (can be disabled) | | Your warehouse endpoints | Database connections | diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 74399b31e4..9611255454 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -13,6 +13,26 @@ Answers to the most common security questions about running Altimate Code in you Altimate Code sends prompts and context to the LLM provider you configure (Anthropic, OpenAI, Azure OpenAI, AWS Bedrock, etc.). **You choose the provider.** No data is sent anywhere else except optional [telemetry](#what-telemetry-is-collected), which contains no code, queries, or credentials. +Altimate Base is an optional hosted provider. Its confirmation dialog explains that requests and +responses are logged and may be used to improve Altimate products and services; do not send +secrets or confidential code. The dialog defaults to **No**, and no registration request is made +unless you explicitly accept. This request logging is part of the Altimate Base service and is +separate from anonymous product telemetry. + +**What identifies you to Altimate Base.** Registration sends a SHA-256 hash of a locally generated +installation secret — the secret itself never leaves your machine. That hash is stable, so logged +requests from this installation are linked to one another. This is deliberate: it is how the free +allowance is enforced. Running `altimate providers logout altimate-base` disconnects the provider +but **keeps** the installation identity, by design, so that logging out and back in cannot mint a +fresh allowance. Each inference request also carries a session identifier used for rate limiting +and abuse control. + +Altimate Base is therefore pseudonymous, not anonymous. To reset the local identity completely, +delete `altimate-base.json` from the application data directory — `$XDG_DATA_HOME/altimate-code/`, +which defaults to `~/.local/share/altimate-code/` on both macOS and Linux — while the app is +closed. This is outside the supported flow, and the gateway applies its own network-level rate +limits. + If you use a self-hosted or VPC-deployed model (e.g., AWS Bedrock, Azure OpenAI), your data never leaves your cloud account. ## Can the AI read my database credentials? @@ -90,6 +110,7 @@ You can also configure per-agent permissions. For example, restrict the `analyst | Destination | Purpose | |-------------|---------| | Your configured LLM provider | Model inference | +| Altimate Base gateway | Registration and inference only after you explicitly enable Altimate Base | | Your warehouse endpoints | Database queries | | `registry.npmjs.org` | Package updates | | `models.dev` | Model catalog (can be disabled) | diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 0737de890d..1da52bdb0e 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -52,9 +52,10 @@ We collect the following categories of events: | `validator_check` | A completion-gate validator ran on session end — validator name, `ok` boolean, step, retry count, `enforced` flag (false in shadow mode), and structured `details` (model counts, elapsed time, concurrency limit — no SQL or model content). Only emitted when `ALTIMATE_VALIDATORS_ENABLED=1` or `ALTIMATE_VALIDATORS_SHADOW=1`. See [Validators](../data-engineering/validators.md). | | `validator_retries_exhausted` | A session terminated with unresolved validator failures after exhausting the synthetic-retry budget — names of the failing validators (no failure body content). | | `onboarding_started` | The first-run setup gate opened (fresh launch with no usable model). | -| `model_picker_shown` | The provider picker was displayed. `trigger` distinguishes the first run from `/connect`, from declining Big Pickle, and from the prompt gate. | -| `provider_selected` | A provider row was chosen — `altimate_gateway`, `anthropic`, `openai`, `google`, `big_pickle`, `search_all`, or `other` for anything outside the curated five. `provider_id` carries the raw id only for publicly-known providers, so a provider you named yourself in config is reported as `other` with no name attached. `via_search` marks a pick made inside the full catalogue after choosing "Search all providers…". **Choosing search emits this event twice for one user** — once as `search_all`, then again with the provider actually chosen — so count distinct users or filter on `via_search`, not raw event count. Recorded at the moment of choice, so a sign-in that is then cancelled still counts. | -| `big_pickle_confirm_shown` / `big_pickle_choice` | The Big Pickle interstitial was shown, and what the user decided (`accept`/`cancel`). | +| `model_picker_shown` | The provider picker was displayed. `trigger` distinguishes the first run from `/connect`, from declining Altimate Base, and from the prompt gate. | +| `provider_selected` | A provider row was chosen — `altimate_gateway`, `altimate_base`, `anthropic`, `openai`, `google`, `search_all`, or `other` for anything outside the curated five. `provider_id` carries the raw id only for publicly-known providers, so a provider you named yourself in config is reported as `other` with no name attached. `via_search` marks a pick made inside the full catalogue after choosing "Search all providers…". **Choosing search emits this event twice for one user** — once as `search_all`, then again with the provider actually chosen — so count distinct users or filter on `via_search`, not raw event count. Recorded at the moment of choice, so a sign-in that is then cancelled still counts. | +| `altimate_base_confirm_shown` / `altimate_base_choice` | The Altimate Base disclosure was shown (`welcome` or `model` origin), and what the user decided (`accept`/`cancel`). | +| `altimate_base_register_result` | The consented registration outcome: `success`, `rate_limited`, `unavailable`, `network`, or `error`. No credential or gateway response body is included. | | `gateway_device_code_issued` | The Altimate Gateway authorize URL was built and the browser open attempted. **Name note:** the flow is a browser loopback OAuth — there is no device code. The name follows the original event spec. | | `gateway_auth_completed` / `gateway_auth_failed` | Gateway sign-in outcome. `reason` is `timeout`, `denied`, or `error` — never the underlying message, which can contain the instance name. An unrecognised callback state does not reject the pending attempt, so a CSRF mismatch surfaces as `timeout`. | | `instance_connected` | Credentials received and saved. `time_to_connect_ms` runs from the start of the authorize call, so it includes the browser launch. No instance or tenant name is sent. | @@ -65,7 +66,7 @@ We collect the following categories of events: | `activation_menu_shown` | The activation menu was (very likely) rendered. `variant` is `warehouse` or `no_data`. **Derived** — see the note below. | | `activation_job_selected` / `first_job_completed` | Which activation job the user started and, where observable, finished. Completion is reported only for the job that was actually selected, so the two form a coherent pair. **Derived** — see the note below. | | `first_prompt_sent` | The user's first typed message in an onboarding session. Slash commands are excluded, so the hidden `/onboard-connect` submission does not count. | -| `onboarding_abandoned` | The CLI exited during a first run without connecting. `last_stage` is the furthest point reached: `started`, `model_picker`, `provider_setup`, `big_pickle_confirm`, or `gateway_auth`. (`connected` is a funnel position but never a `last_stage` — reaching it means the run completed, which is not an abandonment.) Only emitted for a genuine first run — opening `/connect` as an existing user does not enter the funnel, and abandonment after setup completes is out of scope by definition. Emitted on the exit path under a bounded flush, so the measured rate is a lower bound — see [Delivery & Reliability](#delivery--reliability). | +| `onboarding_abandoned` | The CLI exited during a first run without connecting. `last_stage` is the furthest point reached: `started`, `model_picker`, `provider_setup`, `altimate_base_confirm`, or `gateway_auth`. (`connected` is a funnel position but never a `last_stage` — reaching it means the run completed, which is not an abandonment.) Only emitted for a genuine first run — opening `/connect` as an existing user does not enter the funnel, and abandonment after setup completes is out of scope by definition. Emitted on the exit path under a bounded flush, so the measured rate is a lower bound — see [Delivery & Reliability](#delivery--reliability). | | `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. | | `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. | diff --git a/docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md b/docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md new file mode 100644 index 0000000000..90ea618792 --- /dev/null +++ b/docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md @@ -0,0 +1,672 @@ +# Altimate Base — E2E Test Suite: Spec, Harness Design, Parallel Partition + +Status: Phase 1 (research + design) complete. Not yet implemented. +Scope: PR #1199, branch `codex/altimate-base-release-final`. +Author: research/design pass, 2026-09-04. No test code was written by this pass — this +document is the contract 4-6 implementer agents build against. + +Code read for this plan (all on `codex/altimate-base-release-final`, worktree +`.claude/worktrees/agent-a322d28e57f3ba696`): +- `packages/opencode/src/altimate/free/capability.ts` (consent authority) +- `packages/opencode/src/altimate/free/client.ts` (register/authorizedFetch/gatewayUrl/error mapping) +- `packages/opencode/src/altimate/free/consent.ts` (registration consent gate) +- `packages/opencode/src/altimate/free/store.ts` (credential file store) +- `packages/opencode/src/altimate/free/url.ts` (gateway URL validation) +- `packages/opencode/src/provider/provider.ts` (`altimate-free` provider loader, model def, `defaultModel()`) +- `packages/opencode/src/provider/error.ts` (`ProviderError.parseAPICallError` Altimate Base branches) +- `packages/opencode/src/cli/tui/worker.ts` (the ONE legitimate `issueArmer()` call site) +- `packages/opencode/test/altimate/altimate-base.test.ts` (existing 706-line unit suite — this + plan does NOT duplicate its coverage; see "What's already covered" below) +- `packages/opencode/test/provider/error.test.ts`, `test/provider/provider.test.ts` (existing + cross-cutting coverage) +- Gateway contract, cross-referenced against `/Users/anandgupta/codebase/altimate-gateway`: + `issuer/main.py`, `issuer/litellm_client.py`, `issuer/config.py`, `litellm/config.yaml`, + `litellm/custom_callbacks.py`, `docs/USING-ALTIMATE-BASE.md`. + +**IMPORTANT gateway-contract caveat (flagged, not guessed away):** the gateway repo's checked-out +`main`/`feat/serving-autoscaler` still serves `MAX_OUTPUT_TOKENS=16384` with no +`SERVED_CONTEXT_TOKENS` clamp. The 131072/65536 + dynamic remaining-context-clamp contract that +altimate-code's PR #1199 (and this plan) assumes lives only on gateway branch +`chore/litellm-1.99.0` (commit `22971bc`, PR #14/#15), which is **74 commits ahead of gateway +`main` and not yet merged**. Everything below about the gateway's served limits describes the +`chore/litellm-1.99.0` behavior. See "Branch coordination risk" in Deliverable 4. + +--- + +## What's already covered (do not re-implement) + +`test/altimate/altimate-base.test.ts` already hermetically covers, via `spyOn(globalThis, "fetch")` ++ isolated `XDG_*`/`OPENCODE_TEST_HOME` dirs + the real `issueArmer()`/`issueRedeemer()` pair: + +- Gateway URL precedence + rejection of unsafe URLs (partial — see gaps below). +- Fresh registration, hash-only transmission, dedicated 0600 store, no leak into `auth.json`. +- Unforgeable consent (forged token, self-armed foreign store, second `issueArmer`/`issueRedeemer` throws). +- Idempotent re-registration (reuse of a live credential, no network call). +- Malformed/hostile registration responses (wrong origin, wrong path, wrong model, pre-expired). +- Malformed on-disk credential record repair (only after explicit consent). +- Install-secret persistence across a lost response (retry reuses the same secret/hash). +- Network vs. HTTP-status vs. malformed-response failure classification. +- In-flight registration cancellation (AbortSignal) and logout-race cancellation. +- `authorizedFetch`: fails closed with no credentials, blocks cross-origin targets, overwrites a + stale `Authorization` header, disables redirects, reads the credential store exactly once per + call, never replays a rotated credential issued for a different origin, retries once against a + credential rotated by another consented process. +- The 401 consecutive-count / persist-on-2nd-401 / reset-on-any-non-401 state machine, including + the "concurrent write during the response" race variants. +- The registration consent gate's arm/register wiring in isolation (bounded pending tokens, TTL expiry). + +This plan's job is the **gap** between that file and a genuine end-to-end contract test: model +catalog surfacing, the gateway-URL edge cases that file's "prefers new override" test doesn't +enumerate, the rate-limit/budget/byte-limit **message mapping** (`describeRateLimit`, +`describeRequestTooLarge` — see flagged gap below), the context/output-limit clamp behavior, and +the full register→provider-list→inference round trip against one shared fake gateway. + +**Confirmed untested today (verified by grep, not assumed):** +- `FreeTier.describeRateLimit` / `FreeTier.describeRequestTooLarge` have **zero direct unit tests** + in `altimate-base.test.ts`. They're only exercised indirectly through + `test/provider/error.test.ts`'s `ProviderError.parseAPICallError` tests, which cover exactly two + paths: `throttling_error` with an empty detail (generic burst-limit message) and the byte-limit + 413. **Not covered anywhere**: the `throttling_error` + `"Limit type: tokens"` branch (the + non-retryable per-minute-token message), and **both** `budget_exceeded` branches + (`"ExceededBudget: User="` vs. `"Budget has been exceeded"` vs. the generic fallback). This is a + real gap, not a maybe — Suite 3 below closes it. + +--- + +## Gateway contract reference (traced from code, `chore/litellm-1.99.0`) + +| Concern | Where enforced | Value / shape | +|---|---|---| +| Register endpoint | `issuer/main.py:137` `POST /register` | body `{install_secret_hash, cli_version}` → `{api_key, base_url, model, expires_at}` | +| Registration velocity limit | `issuer/main.py:163` via `gate.check_registration_velocity(ip)` | `429 registration_rate_limited`, `Retry-After` header | +| Registration gate dependency-down | `issuer/main.py:152` | `503 dependency_unavailable`, `Retry-After: 30` | +| Registration rotation timeout | `issuer/main.py:188` | `503 registration_timeout`, `Retry-After: 2` | +| Kill switch (both /register and inference) | `litellm/custom_callbacks.py` `_KillSwitch`, `issuer/main.py:143` | `503 maintenance` | +| Served route allowlist | `custom_callbacks.py:494` `ALLOWED_CALL_TYPES` | non-chat-completion call types → `403 route_not_allowed` | +| Served model allowlist | `custom_callbacks.py:501` | wrong `model` string → `403 model_not_allowed` | +| Request byte limit | `custom_callbacks.py:516` `_enforce_request_size` | `MAX_REQUEST_BYTES=1048576` (1MB); over → `413 request_too_large`, message `"Request is {size} bytes; the free tier limit is {MAX_REQUEST_BYTES} bytes."`, wrapped in LiteLLM's `provider_specific_fields.error` shape (matches `describeRequestTooLarge`'s regex) | +| Output token cap | `custom_callbacks.py:69` `MAX_OUTPUT_TOKENS=65536` | clamps `max_tokens`/`max_completion_tokens` down, never up; defaults it if the client sent neither | +| **Dynamic remaining-context clamp** | `custom_callbacks.py:561` `_clamp_generation_params` | `effective_max = min(MAX_OUTPUT_TOKENS, SERVED_CONTEXT_TOKENS(131072) - estimated_prompt_tokens - CLAMP_MARGIN(512))`; if that's `< OUTPUT_TOKEN_FLOOR(1024)`, the clamp is **skipped entirely** and the request is passed through unmodified so the provider/SGLang returns its own honest over-context error | +| `n` forced to 1 | `custom_callbacks.py:591` | silently rewritten, not rejected | +| Per-key requests-per-minute | `issuer/litellm_client.py:411` `rpm_limit` | `KEY_RPM_LIMIT` env, default `10` | +| Per-key tokens-per-minute | `issuer/litellm_client.py:412` `tpm_limit` | `KEY_TPM_LIMIT` env, default `262144`; enforced by **LiteLLM's own built-in limiter**, not gateway code — its native `throttling_error` body is what `describeRateLimit`'s `/Limit type: tokens/` regex matches | +| Per-key/principal wallet budget | `issuer/litellm_client.py` `ensure_principal`/`sync_wallet_budget` | `GRANT_NEW_PRINCIPAL_USD=0.25` one-time grant per install-secret-derived principal, no reset duration; exhausted → LiteLLM's `auth_checks.py:653` message `"ExceededBudget: User={id} over budget. Spend={x}, Budget={y}"` | +| Global daily ceiling (all keys) | `litellm/config.yaml:105` | `max_budget: 50`, `budget_duration: 1d`; exhausted → LiteLLM's generic `"Budget has been exceeded! Current cost: ... Max budget: ..."` | +| Key TTL | `issuer/litellm_client.py:410` | `KEY_TTL=7d` | +| Content policy | `custom_callbacks.py:539` `_enforce_content_policy` | text-only message parts; non-text part types → `400 content_type_not_allowed` | +| Client params allowlist | `custom_callbacks.py:117` `ALLOWED_CLIENT_PARAMS` | anything else silently stripped, not rejected | +| Response caching | `litellm/config.yaml:110` | disabled — no cross-user cache bleed | + +Note the two distinct 429 "budget" surfaces the client must tell apart from a message string alone +(no error code distinguishes them at the HTTP layer beyond the shared `type: "budget_exceeded"`): +per-principal wallet exhaustion (7-day key lifetime, no reset — "resets tomorrow" in the current +client message is **arguably inaccurate** for the wallet case, since the wallet has no +`budget_duration`; only the global daily ceiling actually resets daily) vs. the global $50/day +ceiling. Flagged as an ambiguity below, not silently corrected. + +--- + +## Deliverable 1 — Scenario Inventory + +Each row: **Suite** (from the partition in Deliverable 3) · **Scenario** · **Expected behavior, traced to a specific line**. + +### A. Registration & install-secret lifecycle (mostly covered by existing file — new suite adds only the gaps) + +| Scenario | Expected behavior | Source | +|---|---|---| +| Fresh anonymous register, no prior state | `POST {gateway}/register` with `install_secret_hash` (sha256 hex) + sanitized `cli_version`; credential written to dedicated store | `client.ts:265-337` | +| Register response missing `expires_at` | Accepted — `expires_at` is optional (`expiresAtPresent` check only fires if present) | `client.ts:302-317` | +| Register response with `expires_at` present but unparseable/past | Rejected as `RegistrationError("response")` | `client.ts:305-317` | +| Gateway returns `429 registration_rate_limited` | `describeRegistrationFailure(429)` → `"Too many Altimate Base registrations from this network right now. Try again later."`, `kind: "http"`, `status: 429` | `client.ts:166,294` | +| Gateway returns `503` (maintenance/dependency/timeout — all three issuer paths return 503) | `describeRegistrationFailure(503)` → `"Altimate Base is temporarily unavailable. Try again later."` | `client.ts:167,294` | +| Gateway returns some other 4xx/5xx (e.g. 400 malformed request, 500) | Generic `"Altimate Base registration failed (HTTP {status})."` | `client.ts:168` | +| Consent gate maps registration outcomes | `createRegistrationConsentGate.register()`: `429→"rate_limited"`, `503→"unavailable"`, network kind→`"network"`, else→`"error"`; `cancelled` kind→`ok:false, result:"error"` with the raw cancellation message | `consent.ts:23-55` — **not directly tested today**; existing file tests the gate's arm/consume wiring but never asserts the `RegistrationResult.result` discriminant against a real `429`/`503` from `registerAfterConsent`. | +| Malformed JSON register response body | `response.json().catch(() => undefined)` → falls into the empty-body branch → `RegistrationError("response")` | `client.ts:297` | +| `install_secret_hash` regex the gateway itself validates | Gateway: `400 invalid_request` if not 64 lowercase hex chars — client always sends a valid one, so this is a **gateway-contract sanity check**, not a client-behavior test; still worth one assertion that a malformed hash the fake gateway would reject never actually gets sent | `issuer/main.py:146-147`, `client.ts:85-87` | + +### B. Consent gate / unforgeability (covered by existing file — no new suite needed; listed for completeness) +Fully covered: default-no (no arm ever called without an explicit `issueArmer()`), TUI-is-the-only-armer (`issueArmer()`/`issueRedeemer()` each throw on 2nd call — proven directly), arm-once/redeem-once, self-armed foreign store is inert against `registerAfterConsent`. **No new tests needed here.** + +### C. Model catalog / provider surfacing (NEW — no coverage of the *catalog* shape, only isolation) + +| Scenario | Expected behavior | Source | +|---|---|---| +| `altimate-free/altimate-base` appears in `Provider.list()` only when `credentialsForLoad()` resolves | `autoload: true` branch requires non-undefined creds; unregistered → `autoload: false`, model absent from the connected set (though the static `database[FreeTier.PROVIDER_ID]` entry always exists — "registered" vs. "connected" is the real distinction to test) | `provider.ts:382-398,1512-1554` | +| Model `limit` is exactly `{context: 131072, output: 65536}` | Static fallback value; **must literally equal** whatever the fake gateway advertises for this plan's assertions to mean anything against the real contract | `provider.ts:1533` | +| Model capabilities: `reasoning: true`, `toolcall: true`, `attachment: false`, `image` output `false` | | `provider.ts:1534-1542` | +| A project `provider.altimate-free` config block cannot rename/re-endpoint/re-model the provider | Already covered in `error.test.ts` ("pinned to the hosted Qwen contract") — re-verify in the new provider-isolation suite only if the harness needs its own instance of this check; otherwise skip, it's genuinely covered | `provider.ts:1185-1187`, `error.test.ts` | +| `Provider.defaultModel()` selects `altimate-free/altimate-base` only as last resort, never when any other provider is connected, never via a project `provider:` allowlist naming it explicitly | `provider.ts:2208-2231` — **UNTESTED**: no test found exercising `defaultModel()`'s Altimate Base branch at all | grep confirms no hits in `test/` for `defaultModel` + `altimate-free` together | +| A `recent` model.json entry naming `altimate-free/altimate-base` is honored only if `!hasProviderAllowlist` | `providerAllowed()` check inside the `recent` loop | `provider.ts:2174-2186` | +| Altimate Base is excluded from the "sort candidates" pass and reachable only via the explicit last-resort branch | `candidates` filter excludes `FreeTier.PROVIDER_ID` | `provider.ts:2216-2229` | +| `Provider.sort()` priority list includes `"altimate-base"` (replacing legacy "Big Pickle") | | `provider.ts:2133` | + +### D. Inference happy path (NEW — full round trip against fake gateway; existing file never calls a real `/v1/chat/completions` shape end to end through the provider) + +| Scenario | Expected behavior | Source | +|---|---|---| +| `authorizedFetch` used as the provider's `fetch` option round-trips a `/v1/chat/completions` call and returns content | `provider.ts:395` wires `fetch: FreeTier.authorizedFetch` directly into the AI SDK's OpenAI-compatible provider options | `provider.ts:390-397` | +| Managed API key placeholder never appears in serialized `Provider.Info`/options (only the real key, injected by `authorizedFetch`, is sent over the wire) | | `client.ts:15`, `error.test.ts` "not.toContain(sk-altimate-base)" pattern reused | +| Credential store isolation from `auth.json` (already covered) | — | `altimate-base.test.ts:118-139` | + +### E. Rate limiting / budget / byte-limit message mapping (NEW — the flagged gap) + +| Scenario | Expected behavior | Source | +|---|---|---| +| 429 `throttling_error`, detail matches `/Limit type: tokens/` | `describeRateLimit` → `{message: "This request is too large for Altimate Base's per-minute token limit. Start a new session or shorten the context, then try again.", retryable: false}` | `client.ts:499-506` | +| 429 `throttling_error`, no "Limit type: tokens" (generic burst), with `retry-after` header | `{message: "Too many requests to Altimate Base right now. Try again in {N}s.", retryable: true}` | `client.ts:507-509` (the no-retry-after variant — "Try again shortly." — is already covered in `error.test.ts`; the retry-after-present variant is **not**) | +| 429 `budget_exceeded`, detail contains `"ExceededBudget: User="` | `{message: "You've used today's free Altimate Base allowance. It resets tomorrow—switch models to keep going.", retryable: false}` — **untested anywhere** | `client.ts:511-517` | +| 429 `budget_exceeded`, detail contains `"Budget has been exceeded"` | `{message: "Altimate Base has reached its shared daily limit. It resets tomorrow—switch models to keep going.", retryable: false}` — **untested anywhere** | `client.ts:518-523` | +| 429 `budget_exceeded`, detail matches neither substring | Generic `"The daily Altimate Base limit has been reached..."` fallback — **untested anywhere** | `client.ts:524-527` | +| 429 with unparseable/absent body | `describeRateLimit` returns `undefined` → `error.ts` falls through to the generic API-error path (not the Altimate-Base-specific rewrite) | `client.ts:493-496`, `error.ts:360-375` | +| 413 `request_too_large`, message includes the `"Request is N bytes; the free tier limit is M bytes"` pattern | `describeRequestTooLarge` extracts KB values and produces `"...(179KB against a 125KB limit)"` — the KB-extraction regex itself is untested (only the end-to-end 413 case in `error.test.ts` is covered, which happens to include a matching body, but no test isolates the regex's parsing) | `client.ts:548-552` | +| 413 without the `request_too_large` code (e.g., unrelated provider 413) | `describeRequestTooLarge` returns `undefined`; falls through to `context_overflow` handling in `error.ts:349` | `client.ts:541`, `error.ts:349-357` | +| These mappings apply **only** when `providerID === FreeTier.PROVIDER_ID`; another provider's 429/413 with an identical body is left alone | Already directly covered in `error.test.ts` for the throttling/byte-limit cases; extend for `budget_exceeded` | `error.ts:335,360` | + +### F. Token/context limits (NEW — this is the dynamic-clamp contract, currently invisible to any TS-side test) + +| Scenario | Expected behavior | Source | +|---|---|---| +| Input well within 131072, no `max_tokens` set | Fake gateway clamps output to `min(65536, 131072 - prompt - 512)`; assert the **client surfaces whatever completion comes back** without complaint — this is really a fake-gateway-fidelity test, since the TS client has no client-side context accounting of its own | `custom_callbacks.py:561-588` — TS side has **no equivalent logic**; the model `limit.context/output` values are advisory to the AI SDK's own truncation, not enforced client-side | +| Requested `max_tokens` above 65536 | Fake gateway clamps down silently (200, not an error) — confirm the TS client doesn't misinterpret a silently-clamped response as an error | `custom_callbacks.py:583-586` | +| Prompt so large that `context_bound < OUTPUT_TOKEN_FLOOR (1024)` | Fake gateway does **not** clamp at all — passes the oversized request through; real SGLang would then return its own context-overflow error, which the client must handle via the generic `isOverflow`/413 `context_overflow` path (NOT the Altimate-Base-specific 413 rewrite, since that only fires for `request_too_large`, a distinct error code from context overflow) | `custom_callbacks.py:568-581`, `error.ts:349-357` | +| Reasoning tokens count toward the output cap | Documented design intent (`provider.ts:1531` comment: "reasoning tokens count toward output") — the fake gateway can simulate this by returning a response whose `reasoning_content` + `content` combined implies the cap was respected; there is no separate client-side accounting to test, so this is effectively a documentation/regression-of-intent check, not a behavioral one | `provider.ts:1526-1533` comment | + +**Ambiguity flagged, not guessed:** whether "input within 131072 accepted / over 131072 rejected" should be a *client* behavior at all is unclear from the code — the TS client performs **no pre-flight token counting or context-limit enforcement**; the `limit.context/output` fields are purely advisory metadata read by the AI SDK / TUI for its own compaction heuristics upstream of `authorizedFetch`. Testing "over 131072 rejected" therefore either (a) tests the AI SDK's generic compaction behavior (out of scope for this suite — that's shared machinery, not Altimate-Base-specific), or (b) tests the fake gateway's simulation of SGLang's real over-context error, which is Suite F's actual job. **Product decision needed:** should this suite assert anything about client-side pre-flight limiting, or is "the gateway enforces it, the client surfaces whatever error comes back" the whole contract? This plan assumes the latter (option b) — flag to the PR author before an implementer builds tests that assume otherwise. + +### G. Gateway URL resolution (mostly covered — one gap) + +| Scenario | Expected behavior | Source | +|---|---|---| +| `ALTIMATE_BASE_GATEWAY_URL` set | Wins over everything | `client.ts:66-69`, tested | +| Only `ALTIMATE_FREE_GATEWAY_URL` set (legacy) | Used | tested | +| Neither env var set, no embedded default (source-mode/tests) | Throws `ConfigurationError`, "not configured" message | `client.ts:71-77`, tested | +| Neither env var set, **embedded `ALTIMATE_BASE_DEFAULT_GATEWAY_URL` present (release build)** | Falls back to the embedded default — **untested**: the existing test file can't easily set this (it's a build-time `declare const`), but the fallback branch (`client.ts:64-69` `embedded` value) is otherwise dead code as far as the test suite is concerned. Needs either a build-injected test double or an explicit note that this is only exercisable via the release build process, not unit tests. | `client.ts:18,64-69` | +| Malformed URLs (http, userinfo, query, fragment, non-URL) | Rejected, tested exhaustively | `url.ts`, `altimate-base.test.ts:100-114` | +| Trailing slashes normalized | Tested | `url.ts:10` | +| `normalizeGatewayUrl` unit-level (not through `gatewayUrl()`) | Not separately tested — acceptable, `gatewayUrl()`'s tests exercise it indirectly and completely | — | + +### H. Credential lifecycle / error handling (mostly covered — two gaps) + +| Scenario | Expected behavior | Source | +|---|---|---| +| Expiry → refresh | Covered (`credentialsForLoad` returns undefined once expired; explicit consent re-registers) | tested | +| Rejected credential → refresh on next explicit consent | Covered | tested | +| 401 counter reset on non-401 (including non-2xx) | Covered exhaustively, including the "concurrent write" races | tested | +| Gateway 5xx during inference (not registration) | `authorizedFetch` has **no special handling** for 5xx — it just returns the `Response` as-is (status !== 401 → clears the unauthorized counter and returns). This needs a test asserting the response passes through untouched (no crash, no swallowed error) so callers (the AI SDK layer) can handle it via the generic error path — **untested today** | `client.ts:461-464` | +| Connection failure / timeout during inference | `authorizedFetch` calls raw `fetch` with no try/catch around the initial `send(active)` — an exception (network error, abort) **propagates as a thrown error**, not a `Response`. Confirm this doesn't crash the process and surfaces as a catchable rejection through the AI SDK's own error handling — **untested today**, and worth confirming this asymmetry (registration wraps network errors into `RegistrationError`, inference does not wrap them at all) is intentional | `client.ts:429-450` — no try/catch around `send(active)` | +| Malformed JSON response body during inference | Not the client's problem — passed through to the AI SDK's own JSON parsing, which is shared machinery, out of scope | — | + +--- + +## Deliverable 2 — Hermetic Test-Harness Design + +### Design summary + +One fake gateway module, `test/altimate/_fixtures/fake-gateway.ts`, implementing the two real +routes (`POST /register`, `POST /v1/chat/completions`) plus deterministic knobs for every error +mode in the table above. It is **not an HTTP server** — it's a `fetch`-shaped handler installed via +`spyOn(globalThis, "fetch")`, exactly the pattern `altimate-base.test.ts` already uses. This keeps +tests hermetic (no port binding, no `bun:test`-vs-real-network races, no CI firewall concerns) and +consistent with the one harness pattern already proven in this codebase. A real local HTTP server +was considered and rejected: it adds port-allocation flakiness and doesn't buy anything, since +`authorizedFetch`/`registerAfterConsent` both go through the global `fetch`, which is already the +seam the existing suite uses. + +Each implementer's suite file: +1. Imports the isolated-XDG-home bootstrap (extracted from `altimate-base.test.ts`'s top-of-file + pattern into a shared helper — see below) so every suite gets its own temp credential store. +2. Imports `FakeGateway` and installs it with `spyOn(globalThis, "fetch")`. +3. Claims `issueArmer()` **once per test file** (it's process-global and one-shot — if two suite + files run in the same worker process, only the first claim wins; see "Cross-file consent + isolation" below for why this is safe under `bun test`'s default file-per-worker model, and the + one thing implementers must NOT do). +4. Uses `FakeGateway`'s knobs to script each scenario, and its optional request log to assert what + was actually sent. + +### Cross-file consent isolation (must read before writing Suite B, C, D, or F) + +`issueArmer()`/`issueRedeemer()` are **process-global singletons** (`capability.ts:63-65`), each +claimable exactly once **per process**, not per file. `bun test` runs each test file in its own +worker process by default (confirmed by the existing suite's comment at `altimate-base.test.ts:76-82` +treating this as safe), so each suite file gets its own fresh module instances and can safely call +`FreeTierCapability.issueArmer()` at module scope, exactly like the existing file does. **Do not** +call `issueArmer()` more than once within one file, and do not assume any ordering or sharing +between suite files — each is independent. If a future harness change makes `bun test` share +workers across files, this assumption breaks; flag it if `--parallel=1`-style config changes are +ever made to the TypeScript CI job. + +### Shared fixture: `test/altimate/_fixtures/altimate-base-harness.ts` + +Extract the isolated-environment bootstrap (`XDG_*`/`OPENCODE_TEST_HOME` + cleanup) from +`altimate-base.test.ts:1-74` into this shared file so every new suite imports it instead of +re-copy-pasting. **This is the one piece of shared refactor allowed to touch the existing test +file** — pull the setup into the fixture, then have `altimate-base.test.ts` import from it too, so +there is exactly one isolated-environment implementation. Everything else in +`altimate-base.test.ts` stays as-is. + +```ts +// test/altimate/_fixtures/altimate-base-harness.ts +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterAll, beforeEach } from "bun:test" + +const ISOLATED_ENV = [ + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "OPENCODE_TEST_HOME", +] as const + +/** + * Call once at module scope in each suite file, BEFORE importing + * `../../src/altimate/free/*` (the client reads Global.Path lazily per-call, but isolating env + * before any import keeps every suite file identical to how the existing altimate-base.test.ts + * already does it). + */ +export function isolateAltimateBaseHome(prefix: string) { + const original = Object.fromEntries(ISOLATED_ENV.map((key) => [key, process.env[key]])) + const home = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)) + process.env.XDG_DATA_HOME = path.join(home, "data") + process.env.XDG_CONFIG_HOME = path.join(home, "config") + process.env.XDG_CACHE_HOME = path.join(home, "cache") + process.env.XDG_STATE_HOME = path.join(home, "state") + process.env.OPENCODE_TEST_HOME = home + + afterAll(() => { + for (const key of ISOLATED_ENV) { + if (original[key] === undefined) delete process.env[key] + else process.env[key] = original[key] + } + fs.rmSync(home, { recursive: true, force: true }) + }) + return home +} + +/** Call in beforeEach: clears gateway env vars, then sets the fake gateway URL. */ +export function resetGatewayEnv(gatewayUrl: string) { + delete process.env.ALTIMATE_BASE_GATEWAY_URL + delete process.env.ALTIMATE_FREE_GATEWAY_URL + process.env.ALTIMATE_BASE_GATEWAY_URL = gatewayUrl +} +``` + +### Fake gateway: `test/altimate/_fixtures/fake-gateway.ts` + +```ts +// test/altimate/_fixtures/fake-gateway.ts +import { spyOn } from "bun:test" + +export const GATEWAY_URL = "https://gateway.test" +export const MODEL_ID = "altimate-base" + +export interface RegisterCall { + url: string + installSecretHash: string + cliVersion: string +} +export interface ChatCall { + url: string + authorization: string | null + body: unknown +} + +type RegisterMode = + | { kind: "ok"; apiKey?: string; expiresAt?: string | null; baseUrl?: string; model?: string } + | { kind: "http"; status: number; headers?: Record } + | { kind: "network" } + | { kind: "malformed-json" } + +type ChatMode = + | { kind: "ok"; content?: string; status?: number } + | { kind: "throttle-tokens" } // 429 throttling_error, "Limit type: tokens" + | { kind: "throttle-burst"; retryAfterSeconds?: number } // 429 throttling_error, generic + | { kind: "budget-wallet" } // 429 budget_exceeded, "ExceededBudget: User=" + | { kind: "budget-global" } // 429 budget_exceeded, "Budget has been exceeded" + | { kind: "budget-unknown" } // 429 budget_exceeded, neither substring + | { kind: "too-large"; requestBytes?: number; limitBytes?: number } + | { kind: "unauthorized" } // 401 + | { kind: "server-error"; status?: number } // 5xx + | { kind: "timeout" } // never resolves until aborted + | { kind: "malformed-json" } + +/** + * Fetch-shaped fake for the two real gateway routes. Install with `.install()` in `beforeEach`, + * script the next response with `.registerNext()` / `.chatNext()`, and read `.registerCalls` / + * `.chatCalls` to assert what was actually sent. One instance per test file (or per describe + * block, for isolation from another block's scripted responses); do not share across files. + */ +export class FakeGateway { + registerCalls: RegisterCall[] = [] + chatCalls: ChatCall[] = [] + private registerQueue: RegisterMode[] = [] + private chatQueue: ChatMode[] = [] + private spy?: ReturnType + + registerNext(mode: RegisterMode) { + this.registerQueue.push(mode) + return this + } + chatNext(mode: ChatMode) { + this.chatQueue.push(mode) + return this + } + + install() { + this.spy = spyOn(globalThis, "fetch").mockImplementation( + (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.endsWith("/register")) return this.handleRegister(url, init) + if (url.includes("/v1/chat/completions")) return this.handleChat(url, init) + throw new Error(`FakeGateway: unhandled URL ${url}`) + }) as typeof fetch, + ) + return this + } + + restore() { + this.spy?.mockRestore() + this.spy = undefined + } + + private async handleRegister(url: string, init?: RequestInit): Promise { + const body = JSON.parse(String(init?.body)) + this.registerCalls.push({ url, installSecretHash: body.install_secret_hash, cliVersion: body.cli_version }) + const mode = this.registerQueue.shift() ?? { kind: "ok" as const } + if (mode.kind === "network") throw new Error("connection reset") + if (mode.kind === "http") return new Response("", { status: mode.status, headers: mode.headers }) + if (mode.kind === "malformed-json") return new Response("{not json", { status: 200 }) + return json({ + api_key: mode.apiKey ?? "sk-altimate-base-fake", + base_url: mode.baseUrl ?? GATEWAY_URL, + model: mode.model ?? MODEL_ID, + ...(mode.expiresAt === null ? {} : { expires_at: mode.expiresAt ?? new Date(Date.now() + 86_400_000).toISOString() }), + }) + } + + private async handleChat(url: string, init?: RequestInit): Promise { + const authorization = new Headers(init?.headers).get("Authorization") + this.chatCalls.push({ url, authorization, body: init?.body ? JSON.parse(String(init.body)) : undefined }) + const mode = this.chatQueue.shift() ?? { kind: "ok" as const } + switch (mode.kind) { + case "ok": + return json({ choices: [{ message: { content: mode.content ?? "hello from altimate-base" } }] }, mode.status ?? 200) + case "throttle-tokens": + return throttleError("Limit type: tokens. Key=sk-fake. Current: 300000, Limit: 262144") + case "throttle-burst": + return throttleError("burst limit exceeded", mode.retryAfterSeconds) + case "budget-wallet": + return budgetError("ExceededBudget: User=principal-fake over budget. Spend=0.26, Budget=0.25") + case "budget-global": + return budgetError("Budget has been exceeded! Current cost: 50.01, Max budget: 50") + case "budget-unknown": + return budgetError("spend limit reached") + case "too-large": { + const size = mode.requestBytes ?? 179_608 + const limit = mode.limitBytes ?? 128_000 + const message = `Request is ${size} bytes; the free tier limit is ${limit} bytes.` + return json( + { + error: { + message, + code: "413", + provider_specific_fields: { error: { code: "request_too_large", message } }, + }, + }, + 413, + ) + } + case "unauthorized": + return new Response("", { status: 401 }) + case "server-error": + return new Response("upstream error", { status: mode.status ?? 500 }) + case "timeout": + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + }) + case "malformed-json": + return new Response("{not json", { status: 200 }) + } + } +} + +function json(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }) +} + +function throttleError(message: string, retryAfterSeconds?: number): Response { + return new Response(JSON.stringify({ error: { type: "throttling_error", message } }), { + status: 429, + headers: retryAfterSeconds !== undefined ? { "retry-after": String(retryAfterSeconds) } : {}, + }) +} + +function budgetError(message: string): Response { + return new Response(JSON.stringify({ error: { type: "budget_exceeded", message } }), { status: 429 }) +} +``` + +### Example test using both fixtures (goes in Suite E — see Deliverable 3) + +```ts +// test/altimate/rate-limit-messages.test.ts +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { randomBytes } from "node:crypto" +import { isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" + +isolateAltimateBaseHome("altimate-base-ratelimit") +const { FreeTier } = await import("../../src/altimate/free/client") +const { FreeTierStore } = await import("../../src/altimate/free/store") +const { FreeTierCapability } = await import("../../src/altimate/free/capability") + +const armProductionConsent = FreeTierCapability.issueArmer() +function consented(): string { + const token = randomBytes(32).toString("hex") + armProductionConsent(token) + return token +} + +const gateway = new FakeGateway() + +beforeEach(async () => { + gateway.install() + await FreeTier.logout() + await FreeTierStore.remove() + resetGatewayEnv(GATEWAY_URL) + gateway.registerNext({ kind: "ok" }) + await FreeTier.registerAfterConsent(consented()) +}) + +afterEach(() => gateway.restore()) + +describe("describeRateLimit", () => { + test("budget_exceeded wallet exhaustion maps to the per-user message", async () => { + gateway.chatNext({ kind: "budget-wallet" }) + const response = await FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + const described = FreeTier.describeRateLimit({ body: await response.text() }) + expect(described).toEqual({ + message: "You've used today's free Altimate Base allowance. It resets tomorrow—switch models to keep going.", + retryable: false, + }) + }) + + test("budget_exceeded global ceiling maps to the shared-limit message", async () => { + gateway.chatNext({ kind: "budget-global" }) + const response = await FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + const described = FreeTier.describeRateLimit({ body: await response.text() }) + expect(described?.message).toContain("shared daily limit") + expect(described?.retryable).toBe(false) + }) + + test("per-minute token limit is non-retryable", async () => { + gateway.chatNext({ kind: "throttle-tokens" }) + const response = await FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + const described = FreeTier.describeRateLimit({ body: await response.text() }) + expect(described?.retryable).toBe(false) + expect(described?.message).toContain("per-minute token limit") + }) +}) +``` + +This is intentionally a **skeleton**, not the finished suite — implementers fill in the rest of +each table row from Deliverable 1 following this exact shape (arrange via `FakeGateway`, act via +`FreeTier`/`Provider`, assert against the table's traced expected behavior). + +--- + +## Deliverable 3 — Parallel Partition + +Six suite files, one shared fixture pair (harness + fake gateway, built by whichever implementer +picks up Suite E first — it's the natural owner since it needs the richest fake-gateway surface; +everyone else imports the finished fixture). All files live under +`packages/opencode/test/altimate/`. + +| # | File | Owns (from Deliverable 1) | Depends on | +|---|---|---|---| +| **A** | `altimate-base-registration-gaps.test.ts` | Registration table gaps only: `describeRegistrationFailure` for 429/503/other via the consent gate's `RegistrationResult.result` discriminant (not yet asserted anywhere); malformed JSON register body. Does **not** re-test anything `altimate-base.test.ts` already covers. | fixture only | +| **B** | `altimate-base-catalog.test.ts` | Deliverable 1 Suite C in full: catalog presence/absence, `limit`/`capabilities` shape, `Provider.defaultModel()`'s Altimate Base branch (registered-and-unregistered, with/without a provider allowlist, with/without a `recent` entry), `Provider.sort()` priority. | fixture only — this one leans on `Instance.restore`/`tmpdir` patterns from `test/provider/provider.test.ts`, not the fake gateway's chat route | +| **C** | `altimate-base-inference-e2e.test.ts` | Deliverable 1 Suite D: full register→provider-list→`authorizedFetch` round trip returning real completion content; managed-key-placeholder-never-serialized check via a live provider instance (not just a mocked `credentialsForLoad`, which is what `error.test.ts` already does — this suite goes one level deeper, through `authorizedFetch` itself). | fixture + fake gateway `chat` route, `ok` mode | +| **D** | `altimate-base-rate-limit-messages.test.ts` | Deliverable 1 Suite E in full: every `describeRateLimit`/`describeRequestTooLarge` branch, including the three `budget_exceeded` variants and the `Limit type: tokens` per-minute case — the confirmed gap. Owns the example test above; extend it to cover every row in Suite E's table. | fixture + fake gateway `chat` route, all throttle/budget/too-large modes — **this suite's implementer builds `fake-gateway.ts` and the harness fixture**, since it needs the fullest surface | +| **E** | `altimate-base-context-clamp.test.ts` | Deliverable 1 Suite F: fake-gateway-side simulation of the dynamic `SERVED_CONTEXT_TOKENS` clamp (silent output clamping, floor-triggered pass-through), plus a documented note resolving or escalating the flagged ambiguity about client-side vs. gateway-side enforcement. **Do not start this suite until the ambiguity in Suite F is resolved** — either the product owner confirms "gateway enforces, client just surfaces" (this plan's assumption) or specifies real client-side pre-flight behavior to test instead. | fixture + fake gateway `chat` route (extend `ChatMode` with a `context-clamp` variant that computes the clamp server-side using the same constants as `custom_callbacks.py`, so this suite is pinned to the real formula, not a guess) | +| **F** | `altimate-base-error-surfacing.test.ts` | Deliverable 1 Suite H gaps: 5xx pass-through during inference (no crash, response returned as-is), connection failure/timeout during inference (throws, doesn't hang, doesn't corrupt credential state), the `timeout` `ChatMode`. | fixture + fake gateway `chat` route, `server-error`/`timeout`/`network` modes | + +**Ownership rule to avoid collisions:** Suite D's implementer builds `_fixtures/fake-gateway.ts` +and `_fixtures/altimate-base-harness.ts` **first**, commits them alone in the first commit of their +branch, and the other five suites branch from (or cherry-pick) that commit before writing their own +tests. This is the one sequencing dependency in an otherwise fully parallel partition — call it out +explicitly when kicking off the swarm so Suite D starts a few minutes ahead of the rest, or have +whichever implementer is fastest stub the fixture files first and let everyone else start against +the stub signature (the interface above is stable enough to code against immediately; only the +`ChatMode`/`RegisterMode` variant list might grow). + +Each suite file is independent at the `bun test` level (no shared mutable state beyond the +process-global consent singletons, which — per the "Cross-file consent isolation" note — are safe +because each file is its own worker process). Suite E is explicitly blocked on a product decision +and should be sequenced last, or built in parallel with a `test.skip` on the ambiguous assertions +until resolved. + +--- + +## Deliverable 4 — CI Integration Plan + +### Where it runs + +No CI configuration changes needed. `packages/opencode/**` is already in the `typescript` path +filter (`.github/workflows/ci.yml:43`) that gates the `TypeScript` job, and that job's "Run tests" +step (`ci.yml:209`) runs `bun test --timeout 90000` with no path restriction — every +`test/altimate/*.test.ts` file this plan adds is picked up automatically in the **MAIN pass** +(the one that runs with `OPENCODE_SKIP_SUBPROCESS=1`, default concurrency). None of these six +suites spawn a real CLI subprocess or bind a port, so none belong in the bounded +`SUBPROCESS_PATHS` pass (`ci.yml:207`) — confirm this stays true; if any suite needs +`Instance.restore`/`tmpdir` filesystem fixtures like Suite B does, that's still in-process and +still belongs in the main pass (that's exactly what `test/provider/provider.test.ts` already does +in the same pass today). + +Runtime budget: six suites of the size sketched here (a few dozen assertions each) add well under +a minute to the main pass at default concurrency — negligible next to the existing "9500+ tests +across 379 files" the job's own comment describes. No dedicated job justified. + +Hermetic guarantee: every suite installs `FakeGateway` via `spyOn(globalThis, "fetch")` before any +`FreeTier` call and isolates `XDG_*`/`OPENCODE_TEST_HOME`, so nothing reaches a real network host or +a real user config directory — matching the existing suite's proven pattern exactly. + +### Live-prod smoke — kept separate, non-blocking + +A live smoke test against the real staging/prod gateway (internal staging hostname, see +`docs/USING-ALTIMATE-BASE.md`, or whatever prod URL PR #1199 ships) is explicitly **not** part +of the hermetic suite above and must not be added to the blocking `TypeScript` CI job. Reasons, +traced from what's already in this codebase for exactly this situation: the Driver E2E job's +comment (`ci.yml:244-246`) states cloud tests requiring real credentials "are NOT run here... run +locally only," and the cloud driver E2E test files (`drivers-snowflake-e2e.test.ts` etc.) use +`skipIf`-based auto-skip when connection env vars are absent rather than failing CI. Follow the same +shape: + +- New file `test/altimate/altimate-base-live-smoke.test.ts`, gated by e.g. + `ALTIMATE_BASE_LIVE_SMOKE_URL`/`ALTIMATE_BASE_LIVE_SMOKE_KEY` (or just reuse + `ALTIMATE_BASE_GATEWAY_URL` if unset → skip) — auto-skips when unset, exactly like the driver E2E + pattern, so it's safe to leave in the repo without ever running in the default CI job. + Alternative name to keep it out of `test/altimate/` glob-scans some tooling may run: place under + `test/altimate/live/` — either is fine, follow whichever convention the driver E2E tests use for + their skip-by-default cloud files. + - Actually verify the exact `skipIf` gating pattern in `drivers-snowflake-e2e.test.ts` (not read + in this pass) before implementing — the citation above about "cloud tests auto-skip" is + confirmed via the `ci.yml` comment, but the precise skip mechanism should be copied, not + reinvented. +- Do not wire it into the `typescript` path filter or the main `bun test` invocation's include list. + It should only run when explicitly invoked locally (`bun test test/altimate/altimate-base-live-smoke.test.ts` + with the real env vars set) or from a separate, manually-triggered or scheduled workflow + (`workflow_dispatch` / `schedule`) that is **not** a required status check on `#1199` or any + follow-up PR. +- Rationale beyond "matches existing convention": live-prod calls against a rate-limited, + budget-capped free-tier gateway are non-deterministic by construction (this plan's own Deliverable + 1 findings — per-minute token limits, a one-time $0.25 wallet grant, a shared $50/day ceiling) and + registering a real key in CI would itself consume budget and could trip the registration-velocity + gate (`issuer/main.py:151`) for every other real user on the same runner IP range. A blocking live + test would therefore be actively hostile to the free tier it's supposed to be testing. + +### Where the suite lands: #1199 vs. a stacked follow-up PR + +**Recommend a stacked follow-up PR**, not adding this suite directly to #1199. Reasoning, checked +directly against #1199's live state (`gh pr view 1199`, checked during this pass): + +- #1199 is already large (five source files + a 706-line existing test file) and its `TypeScript` + CI check is currently **FAILING** (confirmed: `conclusion: "FAILURE"` on the `TypeScript` check, + started 18:15Z, completed 18:28Z on 2026-09-04) — consistent with the "blocked on broken-main" + context this task was given. Landing six more test files on top of a red, already-large PR adds + review surface without unblocking anything; it should merge once, cleanly, after #1199 is green. +- **Branch-coordination risk, flagged explicitly:** another agent is reportedly doing a live-prod + check against this same branch (`codex/altimate-base-release-final`) concurrently with this + research pass. This plan deliberately did not modify anything on that branch's worktree + (`.claude/worktrees/agent-a322d28e57f3ba696`) to avoid colliding with that in-flight work — all + code in this document is written to be copied onto a **fresh branch cut from #1199's tip** by + whichever implementer starts first, not committed in place. Before the implementer swarm starts, + re-pull that branch's tip (it may have moved since this pass read it at commit `c3165628ee`) and + re-verify none of the client.ts/error.ts line numbers cited above have shifted. +- Once #1199 merges, cut `test/altimate-base-e2e-suite` (or similar) from `main`, land the six + files there, and open it as its own PR referencing #1199. This also means the hermetic suite gets + its own clean CI signal instead of being entangled with #1199's existing (and currently failing) + checks, and a reviewer can evaluate "is the test suite good" independently of "is the feature + code good." + +### Gateway-contract version risk (separate from branch coordination, worth its own flag) + +This plan's Deliverable 1 "F" table and the gateway contract reference table are both built against +gateway branch `chore/litellm-1.99.0`, which is **unmerged** to gateway `main` (74 commits ahead, +confirmed via `git log --oneline main..chore/litellm-1.99.0`). If that branch's 131072/65536 + +`SERVED_CONTEXT_TOKENS` clamp work does not land before the fake gateway ships, Suite E +(`altimate-base-context-clamp.test.ts`) would be testing a contract the real gateway doesn't yet +serve — the fake gateway would still be internally consistent and useful for regression protection +once the real gateway catches up, but until then it's validating an aspirational contract, not the +live one. Not a blocker for building the hermetic suite (hermetic tests are allowed to encode the +target contract ahead of the server catching up), but flag it to whoever owns the gateway repo so +the two branches land in the right order, or at minimum so nobody is surprised when a live-prod +smoke test (once one exists) disagrees with what the hermetic suite asserts. + +--- + +## Summary of flagged ambiguities (product decisions needed, not guessed) + +1. **Suite F / client-side context enforcement**: does the TS client need to pre-flight-reject + requests over 131072 tokens, or is "gateway enforces, client surfaces whatever error comes back" + the whole contract? This plan assumes the latter. Blocks Suite E until confirmed. +2. **Wallet-exhaustion message accuracy**: `client.ts:514` says "It resets tomorrow" for the + per-principal wallet-exhausted case, but the wallet (`GRANT_NEW_PRINCIPAL_USD`) has no + `budget_duration` — only the global $50/day ceiling actually resets daily. Confirm whether this + message is intentionally reassuring-but-imprecise (a UX call) or should be corrected once a test + pins the current (possibly wrong) behavior. Not a blocker for testing — Suite D should test the + message **as written today** and flag the discrepancy in its own comment, not silently fix it. +3. **Embedded-default gateway URL fallback** (`client.ts:64-69`, release-build-only): effectively + untestable from `bun test` as currently structured (it's a build-time constant substitution). + Confirm whether this needs a build-injection test double, or is accepted as covered only by the + release build/smoke process. diff --git a/packages/core/src/util/glob.ts b/packages/core/src/util/glob.ts index cd73b40ffc..d2509cfa9d 100644 --- a/packages/core/src/util/glob.ts +++ b/packages/core/src/util/glob.ts @@ -27,6 +27,7 @@ export namespace Glob { "**/vendor/**", "**/.git/**", "**/.pnpm/**", + "**/.yarn/unplugged/**", "**/.venv/**", "**/.turbo/**", ] diff --git a/packages/core/test/util/glob.test.ts b/packages/core/test/util/glob.test.ts index d305552b17..847b4f1590 100644 --- a/packages/core/test/util/glob.test.ts +++ b/packages/core/test/util/glob.test.ts @@ -16,12 +16,14 @@ beforeAll(async () => { root = await mkdtemp(path.join(tmpdir(), "glob-ignore-")) await mkdir(path.join(root, "src"), { recursive: true }) await mkdir(path.join(root, "node_modules", "pkg", ".vscode"), { recursive: true }) + await mkdir(path.join(root, ".yarn", "unplugged", "pkg"), { recursive: true }) await mkdir(path.join(root, "vendor"), { recursive: true }) await mkdir(path.join(root, "dist"), { recursive: true }) await mkdir(path.join(root, ".vscode"), { recursive: true }) await writeFile(path.join(root, ".vscode", "mcp.json"), "{}") await writeFile(path.join(root, "src", "mcp.json"), "{}") await writeFile(path.join(root, "node_modules", "pkg", ".vscode", "mcp.json"), "{}") + await writeFile(path.join(root, ".yarn", "unplugged", "pkg", "mcp.json"), "{}") await writeFile(path.join(root, "vendor", "mcp.json"), "{}") await writeFile(path.join(root, "dist", "mcp.json"), "{}") }) @@ -37,6 +39,7 @@ describe("Glob.scan ignore", () => { const found = (await Glob.scan("**/mcp.json", { cwd: root, absolute: true, dot: true })).map(rel).sort() expect(found).toEqual([ ".vscode/mcp.json", + ".yarn/unplugged/pkg/mcp.json", "dist/mcp.json", "node_modules/pkg/.vscode/mcp.json", "src/mcp.json", @@ -105,7 +108,7 @@ describe("Glob.DEFAULT_IGNORE", () => { }) test("covers the package-manager, VCS and build output directories", () => { - for (const dir of ["node_modules", ".git", "dist", "build", "target", ".venv"]) { + for (const dir of ["node_modules", ".git", ".yarn/unplugged", "dist", "build", "target", ".venv"]) { expect(Glob.DEFAULT_IGNORE).toContain(`**/${dir}/**`) } }) diff --git a/packages/drivers/src/clickhouse.ts b/packages/drivers/src/clickhouse.ts index 8e2ee98e06..d6da71b1d0 100644 --- a/packages/drivers/src/clickhouse.ts +++ b/packages/drivers/src/clickhouse.ts @@ -8,10 +8,24 @@ import type { ConnectionConfig, Connector, ConnectorResult, ExecuteOptions, SchemaColumn } from "./types" import { loadOptionalDriver } from "./resolve" +function tlsFlagEnabled(value: unknown): boolean { + if (typeof value !== "string") return Boolean(value) + const normalized = value.trim().toLowerCase() + if (["", "0", "false", "no", "off"].includes(normalized)) return false + return true +} + +function tlsRequested(config: ConnectionConfig): boolean { + return [config.tls, config.ssl, config.secure].some(tlsFlagEnabled) +} + function connectionUrl(config: ConnectionConfig): string { - const tlsRequested = Boolean(config.tls || config.ssl) + // `secure` is dbt-clickhouse's standard TLS flag. Enforce it here at the + // driver boundary as well as preserving it through profile normalization, + // because direct driver consumers can bypass the dbt importer. + const requested = tlsRequested(config) const configuredProtocol = typeof config.protocol === "string" ? config.protocol.trim().toLowerCase() : "" - const secureIntent = tlsRequested || configuredProtocol === "https" + const secureIntent = requested || configuredProtocol === "https" const configured = typeof config.connection_string === "string" ? config.connection_string.trim() : "" if (configured) { @@ -29,11 +43,11 @@ function connectionUrl(config: ConnectionConfig): string { return configured } - if (tlsRequested && configuredProtocol && configuredProtocol !== "https") { + if (requested && configuredProtocol && configuredProtocol !== "https") { throw new Error("ClickHouse TLS was requested, but protocol is not https") } - const protocol = configuredProtocol || (tlsRequested ? "https" : "http") + const protocol = configuredProtocol || (requested ? "https" : "http") const defaultPort = protocol === "https" ? 8443 : 8123 const hasExplicitPort = config.port !== undefined && config.port !== null const parsedPort = @@ -76,9 +90,9 @@ export async function connect(config: ConnectionConfig): Promise { if (config.password) clientConfig.password = config.password as string if (config.database) clientConfig.database = config.database as string - // TLS/SSL support — detect HTTPS from URL, protocol config, or explicit tls/ssl flags + // TLS/SSL support — detect HTTPS from URL, protocol config, or an explicit secure flag const isHttps = typeof url === "string" && url.startsWith("https://") - if (config.tls || config.ssl || (config.protocol as string) === "https" || isHttps) { + if (tlsRequested(config) || (config.protocol as string) === "https" || isHttps) { const tls: Record = {} if (config.tls_ca_cert) tls.ca_cert = config.tls_ca_cert if (config.tls_cert) tls.cert = config.tls_cert diff --git a/packages/drivers/test/clickhouse-unit.test.ts b/packages/drivers/test/clickhouse-unit.test.ts index 123a24a96d..ae6acbc8fa 100644 --- a/packages/drivers/test/clickhouse-unit.test.ts +++ b/packages/drivers/test/clickhouse-unit.test.ts @@ -74,6 +74,33 @@ describe("ClickHouse driver unit tests", () => { expect(mockClientConfigs.at(-1).url).toBe("https://secure.example:8443") }) + test("dbt secure defaults to HTTPS and the secure HTTP port", async () => { + const secure = await connect({ type: "clickhouse", host: "secure.example", secure: true }) + await secure.connect() + + expect(mockClientConfigs.at(-1).url).toBe("https://secure.example:8443") + }) + + for (const [flag, value] of [ + ["tls", "false"], + ["ssl", " false "], + ["secure", "FALSE"], + ] as const) { + test(`treats serialized ${flag}: ${JSON.stringify(value)} as disabled`, async () => { + const plaintext = await connect({ type: "clickhouse", host: "plain.example", [flag]: value }) + await plaintext.connect() + + expect(mockClientConfigs.at(-1).url).toBe("http://plain.example:8123") + }) + } + + test("treats serialized dbt secure true as enabled", async () => { + const secure = await connect({ type: "clickhouse", host: "secure.example", secure: "true" }) + await secure.connect() + + expect(mockClientConfigs.at(-1).url).toBe("https://secure.example:8443") + }) + test("an HTTPS protocol defaults to the secure HTTP port", async () => { const secure = await connect({ type: "clickhouse", host: "secure.example", protocol: "https" }) await secure.connect() @@ -117,6 +144,19 @@ describe("ClickHouse driver unit tests", () => { expect(mockClientConfigs).toHaveLength(1) }) + test("rejects an explicit plaintext connection string when dbt secure is requested", async () => { + const insecure = await connect({ + type: "clickhouse", + connection_string: "http://secure.example:8123", + secure: true, + user: "analyst", + password: "secret", + }) + + await expect(insecure.connect()).rejects.toThrow("connection_string is not https://") + expect(mockClientConfigs).toHaveLength(1) + }) + test("rejects an explicit plaintext protocol when TLS is requested", async () => { const insecure = await connect({ type: "clickhouse", host: "secure.example", protocol: "http", ssl: true }) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 92e1eee0cc..b253306912 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -19,6 +19,7 @@ import { Script } from "@opencode-ai/script" import pkg from "../package.json" import { walkInputs } from "./stamp-inputs" import { assertUsableCatalog, catalogDiagnosticOrigin, formatCatalogSummary } from "./models-catalog" +import { FreeTierUrl } from "../src/altimate/free/url" // Python engine has been eliminated — all methods run natively in TypeScript. // ALTIMATE_ENGINE_VERSION is no longer needed at runtime. @@ -28,6 +29,21 @@ const changelogPath = path.resolve(dir, "../../CHANGELOG.md") const changelog = fs.existsSync(changelogPath) ? await Bun.file(changelogPath).text() : "" console.log(`Loaded CHANGELOG.md (${changelog.length} chars)`) +// altimate_change start — inject the official Altimate Base endpoint at release time +const rawAltimateBaseGatewayUrl = process.env.ALTIMATE_BASE_GATEWAY_URL?.trim() ?? "" +const altimateBaseGatewayUrl = rawAltimateBaseGatewayUrl + ? FreeTierUrl.normalizeGatewayUrl(rawAltimateBaseGatewayUrl) + : undefined +if (rawAltimateBaseGatewayUrl && !altimateBaseGatewayUrl) { + console.error("error: ALTIMATE_BASE_GATEWAY_URL must be HTTPS and contain no credentials, query, or fragment") + process.exit(1) +} +if (Script.release && !altimateBaseGatewayUrl) { + console.error("error: release builds require ALTIMATE_BASE_GATEWAY_URL") + process.exit(1) +} +// altimate_change end + const modelsUrlOverride = process.env.OPENCODE_MODELS_URL || undefined const modelsUrl = modelsUrlOverride ?? "https://models.dev" @@ -608,6 +624,8 @@ for (const item of targets) { define: { OPENCODE_VERSION: `'${Script.version}'`, OPENCODE_CHANNEL: `'${Script.channel}'`, + // altimate_change — official default is release configuration; runtime env can still override it + ALTIMATE_BASE_DEFAULT_GATEWAY_URL: JSON.stringify(altimateBaseGatewayUrl ?? ""), // ALTIMATE_ENGINE_VERSION removed — Python engine eliminated OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined", OPENCODE_MIGRATIONS: JSON.stringify(migrations), diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index a65ea7a308..b69c30eeac 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -166,7 +166,9 @@ export function make(input: { const newSession = Effect.fn("ACP.newSession")(function* (params: NewSessionRequest) { const started = performance.now() const snapshot = yield* directorySnapshot(params.cwd) - const selected = selectDefaultModel(snapshot) + // altimate_change start — fail closed when Big Pickle is the only implicit ACP option + const selected = yield* requireDefaultModel(snapshot) + // altimate_change end const variant = selectVariant(snapshot, selected) const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined const created = yield* profiledRequest( @@ -222,13 +224,19 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(snapshot) + // altimate_change start — fail closed when a legacy session has no usable model and do not pair a fallback with stale effort + const restoredModel = availableModel(snapshot, restored.model) + const model = restoredModel ?? (yield* requireDefaultModel(snapshot)) + const variant = selectRestoredVariant(snapshot, model, restored.variant, restoredModel !== undefined) + // altimate_change end const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, mcpServers: params.mcpServers, model, - variant: restored.variant ?? selectVariant(snapshot, model), + // altimate_change start — use the model-coupled restored effort selected above + variant, + // altimate_change end modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) sessionSnapshots.set(state.id, snapshot) @@ -307,13 +315,19 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(snapshot) + // altimate_change start — fail closed when a resumed session has no usable model and do not pair a fallback with stale effort + const restoredModel = availableModel(snapshot, restored.model) + const model = restoredModel ?? (yield* requireDefaultModel(snapshot)) + const variant = selectRestoredVariant(snapshot, model, restored.variant, restoredModel !== undefined) + // altimate_change end const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, mcpServers: params.mcpServers ?? [], model, - variant: restored.variant ?? selectVariant(snapshot, model), + // altimate_change start — use the model-coupled restored effort selected above + variant, + // altimate_change end modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) sessionSnapshots.set(state.id, snapshot) @@ -359,6 +373,20 @@ export function make(input: { const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) + // altimate_change start — resolve the source model before persisting an ACP fork + // Resolve a usable model from the source session before creating any persistent fork. The + // forked transcript is read again below because the server may trim it at the fork boundary. + const sourceMessages = yield* request( + () => + input.sdk.session.messages( + { directory: params.cwd, sessionID: params.sessionId, limit: 20 }, + { throwOnError: true }, + ), + "session", + ) + const sourceRestored = restoreFromMessages(sourceMessages.map((item) => item.info)) + const fallbackModel = availableModel(snapshot, sourceRestored.model) ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end const forked = yield* request( () => input.sdk.session.fork( @@ -376,13 +404,19 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(snapshot) + // altimate_change start — fail closed when a fork has no usable model and do not pair a fallback with stale effort + const restoredModel = availableModel(snapshot, restored.model) + const model = restoredModel ?? fallbackModel + const variant = selectRestoredVariant(snapshot, model, restored.variant, restoredModel !== undefined) + // altimate_change end const state = yield* session.load({ id: forked.id, cwd: params.cwd, mcpServers: params.mcpServers ?? [], model, - variant: restored.variant ?? selectVariant(snapshot, model), + // altimate_change start — use the model-coupled restored effort selected above + variant, + // altimate_change end modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) sessionSnapshots.set(state.id, snapshot) @@ -426,7 +460,9 @@ export function make(input: { } if (params.configId === "effort") { - const model = current.model ?? selectDefaultModel(snapshot) + // altimate_change start — effort selection requires a real, advertised model + const model = current.model ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end const variants = Directory.variants(snapshot, model) if (!variants || !Object.keys(variants).includes(params.value)) { return yield* new ACPError.InvalidEffortError({ effort: params.value }) @@ -445,10 +481,15 @@ export function make(input: { if (!snapshot.availableModes.some((mode) => mode.id === params.value)) { return yield* new ACPError.InvalidModeError({ mode: params.value }) } + // altimate_change start — validate the complete resulting state before mutating the session mode + const model = current.model ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end const state = yield* session.setMode(params.sessionId, params.value) return { configOptions: configOptions(snapshot, { - model: state.model ?? selectDefaultModel(snapshot), + // altimate_change start — mode selection cannot fabricate an ACP model + model: state.model ?? model, + // altimate_change end variant: state.variant, modeId: state.modeId, }), @@ -498,7 +539,9 @@ export function make(input: { prompt: Effect.fn("ACP.prompt")(function* (params: PromptRequest) { const current = yield* session.get(params.sessionId) const snapshot = yield* directorySnapshot(current.cwd) - const selected = current.model ?? selectDefaultModel(snapshot) + // altimate_change start — prompts require a real, advertised model + const selected = current.model ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end if (!current.model) { yield* session.setModel(params.sessionId, selected) } @@ -744,12 +787,36 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { ProviderV2.ID, Provider.Info > + // altimate_change start — keep the managed provider out of ACP unless this project allows it + const config = configResponse?.data + const configLoaded = config !== undefined + const withoutManagedBase = () => + Object.fromEntries(Object.entries(providers).filter(([id]) => id !== "altimate-free")) as Record< + ProviderV2.ID, + Provider.Info + > + const hasProviderAllowlist = Object.keys(config?.provider ?? {}).length > 0 + // `config.provider` is a per-provider CUSTOMIZATION map (apiKey, options, headers) — the docs + // demonstrate it as a single-entry block. It gates ONLY the consent-gated managed provider, + // which config must never be able to switch on. Every other connected provider stays + // advertised, so `provider: { anthropic: {...} }` does not hide the user's other authenticated + // models from the ACP catalogue or invalidate a restored session pinned to one of them. + // A failed config lookup cannot prove that this project permits the request-logging managed + // provider either, so it fails closed the same way an explicit allowlist without it does. + const snapshotProviders = configLoaded && !hasProviderAllowlist ? providers : withoutManagedBase() + // altimate_change end const defaultModelStarted = performance.now() + // altimate_change start — resolve the default against the SAME filtered snapshot advertised to + // the client. Resolving against the unfiltered `providers` map let a project that sets + // `model: "altimate-free/altimate-base"` alongside any `provider` allowlist end up with a + // `defaultModel` pointing at a provider this snapshot had just excluded — ACP would still + // select and route the managed model even though it was hidden from `modelOptions`. const defaultModel = defaultModelFromConfig( - configResponse?.data?.model, - providers, - configResponse?.data?.provider as Record | undefined, + config?.model, + snapshotProviders, + config?.provider as Record | undefined, ) + // altimate_change end ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) const modes = agents .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) @@ -773,7 +840,9 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { return Directory.build({ directory, - providers, + // altimate_change start — expose only providers admitted by the ACP snapshot policy above + providers: snapshotProviders, + // altimate_change end modes, defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), @@ -797,6 +866,9 @@ export function defaultModelFromConfig( : undefined if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured + const configuredProviderEntries = Object.keys(providerFilter ?? {}) + const hasProviderAllowlist = configuredProviderEntries.length > 0 + // Prefer altimate-backend/altimate-default when the fork's backend is available and the user // hasn't pinned a model — restores dropped fork behavior (the merge fell straight through to the // opencode provider, routing ACP clients away from altimate's backend). Honors an explicit @@ -805,7 +877,7 @@ export function defaultModelFromConfig( if ( altimateProvider && altimateProvider.models[ModelV2.ID.make("altimate-default")] && - (!providerFilter || Object.keys(providerFilter).includes("altimate-backend")) + (!hasProviderAllowlist || configuredProviderEntries.includes("altimate-backend")) ) { return { providerID: ProviderV2.ID.make("altimate-backend"), modelID: ModelV2.ID.make("altimate-default") } } @@ -813,23 +885,68 @@ export function defaultModelFromConfig( // First-session ACP startup must not scan historical sessions just to infer // a default. Configured model, opencode provider, then sorted best model keep // the protocol response deterministic without extra session/message reads. - const opencodeProvider = providers[ProviderV2.ID.make("opencode")] - const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined + const providerAllowed = (id: string) => + id !== "altimate-free" && (!hasProviderAllowlist || Object.prototype.hasOwnProperty.call(providerFilter, id)) + const opencodeProvider = providerAllowed("opencode") ? providers[ProviderV2.ID.make("opencode")] : undefined + const opencodeModel = opencodeProvider + ? Provider.sort(Object.values(opencodeProvider.models)).find((model) => model.id !== "big-pickle") + : undefined if (opencodeProvider && opencodeModel) return { providerID: ProviderV2.ID.make(opencodeProvider.id), modelID: ModelV2.ID.make(opencodeModel.id) } - const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0] + const best = Provider.sort( + Object.values(providers) + .filter((provider) => providerAllowed(provider.id)) + .flatMap((provider) => Object.values(provider.models)), + ).find((model) => !(model.providerID === "opencode" && model.id === "big-pickle")) if (best) return { providerID: ProviderV2.ID.make(best.providerID), modelID: ModelV2.ID.make(best.id) } - if (configured) return configured + + // Altimate Base replaces Big Pickle as the free fallback, but only as a LAST resort and only + // after the user consented and registered (which is why it is present in `providers`). Anything + // else connected outranks the request-logging tier. A project provider block cannot force the + // managed model; an explicit configured model above remains authoritative. + const baseProvider = providers[ProviderV2.ID.make("altimate-free")] + if (!hasProviderAllowlist && baseProvider?.models[ModelV2.ID.make("altimate-base")]) { + return { providerID: ProviderV2.ID.make("altimate-free"), modelID: ModelV2.ID.make("altimate-base") } + } + return undefined // altimate_change end } -function selectDefaultModel(snapshot: Directory.Snapshot) { +// altimate_change start — keep Big Pickle explicitly selectable but never choose it implicitly +export function selectDefaultModel(snapshot: Directory.Snapshot) { if (snapshot.defaultModel) return snapshot.defaultModel - const model = snapshot.modelOptions[0] + // Big Pickle remains explicitly selectable for existing users, but Altimate Base replaces it as + // the free implicit choice. Do not silently route a new ACP session back to Big Pickle when it is + // the first (or only) sorted catalogue entry and no usable default was resolved above. + const model = snapshot.modelOptions.find( + (item) => !(item.providerID === ProviderV2.ID.make("opencode") && item.modelID === ModelV2.ID.make("big-pickle")), + ) if (model) return { providerID: model.providerID, modelID: model.modelID } - return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ModelV2.ID } + return undefined +} + +function availableModel(snapshot: Directory.Snapshot, model: Directory.DefaultModel | undefined) { + if (!model) return undefined + return snapshot.modelOptions.some( + (option) => option.providerID === model.providerID && option.modelID === model.modelID, + ) + ? model + : undefined +} + +function requireDefaultModel(snapshot: Directory.Snapshot) { + const selected = selectDefaultModel(snapshot) + return selected + ? Effect.succeed(selected) + : Effect.fail( + new ACPError.ServiceFailureError({ + safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.", + service: "model", + }), + ) } +// altimate_change end function detectSlashCommand(parts: ReturnType) { const text = parts @@ -875,6 +992,26 @@ function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultMod return Object.keys(variants)[0] } +// altimate_change start — restored effort belongs to its restored model; validate both as one selection +function selectRestoredVariant( + snapshot: Directory.Snapshot, + model: Directory.DefaultModel, + restoredVariant: string | undefined, + restoredModelRetained: boolean, +) { + const variants = Directory.variants(snapshot, model) + if ( + restoredModelRetained && + restoredVariant && + variants && + Object.prototype.hasOwnProperty.call(variants, restoredVariant) + ) { + return restoredVariant + } + return selectVariant(snapshot, model) +} +// altimate_change end + function configOptions(snapshot: Directory.Snapshot, session: ConfigState) { return buildConfigOptions({ providers: Object.values(snapshot.providers), @@ -1083,3 +1220,7 @@ function findProviderID(value: unknown): string | undefined { if ("data" in value) return findProviderID(value.data) if ("error" in value) return findProviderID(value.error) } + +// altimate_change start — expose the module through the repository's namespace projection convention +export * as ACPService from "./service" +// altimate_change end diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 7b9382f353..daad669027 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -2,8 +2,8 @@ import { readFile } from "fs/promises" import path from "path" import { parseTree, findNodeAtLocation, getNodeValue } from "jsonc-parser" import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk } from "../mcp/config" +import { DiscoveryFiles } from "../mcp/discovery-files" import { Filesystem } from "../util/filesystem" -import { Glob } from "@opencode-ai/core/util/glob" import { Log } from "@/altimate/util/log" import type { Config } from "../config/config" @@ -45,25 +45,7 @@ function extractServersMap( */ async function findAllMcpJsonFiles(projectRootDir: string): Promise { try { - const ignore = [...Glob.DEFAULT_IGNORE] - const paths = await Glob.scan("**/mcp.json", { - cwd: projectRootDir, - absolute: true, - dot: true, - // Prune dependency/build trees during traversal. Filtering the results - // afterwards still reads every directory: on a monorepo with - // node_modules installed that walk costs ~6 CPU-seconds per invocation - // because it runs across the whole runtime I/O thread pool. - ignore, - }) - // Belt and braces: keep the result filter so a pattern that slips past the - // traversal prune (e.g. via a symlinked path) still never reaches - // StdioClientTransport, which is handed `command` + `args` from whatever - // mcp.json we discover. - const toRelativeGlobPath = (file: string) => path.relative(projectRootDir, file).split(path.sep).join("/") - return paths - .filter((file) => !ignore.some((pattern) => Glob.match(pattern, toRelativeGlobPath(file)))) - .sort() + return (await DiscoveryFiles.scanProjectMcpJsonFiles(projectRootDir)).map((file) => file.path) } catch { log.warn("findAllMcpJsonFiles: glob scan failed", { cwd: projectRootDir }) return [] diff --git a/packages/opencode/src/altimate/free/capability.ts b/packages/opencode/src/altimate/free/capability.ts new file mode 100644 index 0000000000..d61f7301be --- /dev/null +++ b/packages/opencode/src/altimate/free/capability.ts @@ -0,0 +1,95 @@ +const TOKEN_PATTERN = /^[0-9a-f]{64}$/ +const DEFAULT_TTL_MS = 30_000 +const DEFAULT_MAX_PENDING = 16 + +/** + * Worker-local, short-lived capabilities proving that a disclosure action was accepted. + * Multiple dialogs may overlap, so consuming or rejecting one token must not invalidate another. + * + * This lives in its own leaf module so the registration client can depend on it without a cycle: + * registration checks a token against this module's private authority before touching the + * network, which makes consent a property of the operation rather than of its call sites. + * + * The class stays exported so its arm/consume/TTL/bounding mechanics are directly unit + * testable in isolation — but that export is inert for security purposes: `registerAfterConsent` + * never accepts a caller-supplied instance, so a store you construct yourself only ever validates + * against itself. The one instance that matters (`productionAuthority` below) is never exported; + * the only way to influence it is `issueArmer()`/`issueRedeemer()`, each of which can be claimed + * exactly once per process. See those functions for the actual unforgeability guarantee. + */ +export class ConsentCapabilityStore { + private readonly pending = new Map() + private readonly ttlMs: number + private readonly maxPending: number + private readonly now: () => number + + constructor(input: { ttlMs?: number; maxPending?: number; now?: () => number } = {}) { + this.ttlMs = Math.max(1, input.ttlMs ?? DEFAULT_TTL_MS) + this.maxPending = Math.max(1, input.maxPending ?? DEFAULT_MAX_PENDING) + this.now = input.now ?? Date.now + } + + private cleanup(now: number): void { + for (const [token, expiresAt] of this.pending) { + if (expiresAt <= now) this.pending.delete(token) + } + } + + arm(token: string): void { + if (!TOKEN_PATTERN.test(token)) throw new Error("Invalid Altimate Base consent capability") + const now = this.now() + this.cleanup(now) + this.pending.delete(token) + while (this.pending.size >= this.maxPending) { + const oldest = this.pending.keys().next().value + if (!oldest) break + this.pending.delete(oldest) + } + this.pending.set(token, now + this.ttlMs) + } + + consume(token: string): boolean { + if (!TOKEN_PATTERN.test(token)) return false + const now = this.now() + this.cleanup(now) + if (!this.pending.has(token)) return false + this.pending.delete(token) + return true + } +} + +// The process's ONE production consent authority. Never exported — the only way to reach it is +// through `issueArmer`/`issueRedeemer` below, each claimable exactly once. +const productionAuthority = new ConsentCapabilityStore() +let armerIssued = false +let redeemerIssued = false + +/** + * Hands out the ability to arm Altimate Base's production consent authority. Callable exactly + * once per process: a second call throws. The sole legitimate caller is the registration consent + * gate built once at TUI worker boot (`cli/tui/worker.ts`), before any plugin, tool, or session + * code has a chance to run. Because this is the only way to arm the authority that + * `registerAfterConsent` checks against, no other in-process code — however it constructs its + * own `ConsentCapabilityStore` or calls this function again — can mint a token that will ever be + * accepted; a self-armed store only ever validates against itself. + */ +export function issueArmer(): (token: string) => void { + if (armerIssued) throw new Error("Altimate Base consent armer already issued for this process") + armerIssued = true + return (token) => productionAuthority.arm(token) +} + +/** + * Hands out the ability to redeem (consume) a token against the production consent authority. + * Callable exactly once per process — `registerAfterConsent` claims it at module load, so + * registration can verify consent without ever accepting a capability object a caller could + * substitute. Pairs with `issueArmer`: only a token armed through that function's closure can + * ever redeem here, because both close over the same private `productionAuthority`. + */ +export function issueRedeemer(): (token: string) => boolean { + if (redeemerIssued) throw new Error("Altimate Base consent redeemer already issued for this process") + redeemerIssued = true + return (token) => productionAuthority.consume(token) +} + +export * as FreeTierCapability from "./capability" diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts new file mode 100644 index 0000000000..2c7dbff8aa --- /dev/null +++ b/packages/opencode/src/altimate/free/client.ts @@ -0,0 +1,562 @@ +import { createHash, randomBytes } from "node:crypto" +import { Flock } from "@opencode-ai/core/util/flock" +import { FreeTierCapability } from "./capability" +import { Installation } from "../../installation" +import { Log } from "../util/log" +import { FreeTierStore } from "./store" +import { FreeTierUrl } from "./url" + +const log = Log.create({ service: "altimate-base" }) + +export const PROVIDER_ID = "altimate-free" +export const MODEL_ID = "altimate-base" +// The OpenAI-compatible SDK requires a non-empty key, but the real managed key must never enter +// Provider.Info/options because those objects are returned by public provider endpoints. +export const MANAGED_API_KEY_PLACEHOLDER = "altimate-base-managed" +// Release builds replace this identifier with the current official endpoint. +// Source-mode development and tests intentionally have no implicit network host. +declare const ALTIMATE_BASE_DEFAULT_GATEWAY_URL: string | undefined + +const REGISTER_TIMEOUT_MS = 15_000 +const LOCK_KEY = "altimate-base-registration" +const inflight = new Map>() +const rejectedCredentials = new Set() +const REJECTED_CREDENTIAL_LIMIT = 32 +// A credential is only disowned on disk after this many 401s in a row. One 401 can come from a +// gateway deploy, an LB restart, or key-propagation skew; persisting on the first one would take +// the whole free tier offline until every user re-ran the disclosure flow. +const REJECTED_PERSIST_THRESHOLD = 2 +const unauthorizedCounts = new Map() +// Claimed once, at module load: this module is the ONE place that may redeem an Altimate Base +// consent token. `issueRedeemer` throws on a second call, so no other in-process code can obtain +// an equivalent redeemer bound to the same production authority — see capability.ts. +const redeemConsent = FreeTierCapability.issueRedeemer() + +export interface Credentials { + apiKey: string + baseURL: string + expiresAt?: string + installSecret: string + rejected?: boolean +} + +export type RegistrationFailureKind = "network" | "http" | "response" | "cancelled" + +export class RegistrationError extends Error { + constructor( + message: string, + readonly kind: RegistrationFailureKind, + readonly status?: number, + ) { + super(message) + this.name = "AltimateBaseRegistrationError" + } +} + +export class ConfigurationError extends Error { + constructor(message: string) { + super(message) + this.name = "AltimateBaseConfigurationError" + } +} + +export function gatewayUrl(): string { + const embedded = + typeof ALTIMATE_BASE_DEFAULT_GATEWAY_URL === "string" ? ALTIMATE_BASE_DEFAULT_GATEWAY_URL.trim() : "" + const configured = + process.env["ALTIMATE_BASE_GATEWAY_URL"]?.trim() || + process.env["ALTIMATE_FREE_GATEWAY_URL"]?.trim() || + embedded + const normalized = FreeTierUrl.normalizeGatewayUrl(configured) + if (!normalized) { + throw new ConfigurationError( + configured + ? "ALTIMATE_BASE_GATEWAY_URL must be HTTPS and cannot contain credentials, a query, or a fragment." + : "The Altimate Base gateway is not configured. Set ALTIMATE_BASE_GATEWAY_URL and try again.", + ) + } + return normalized +} + +function mintInstallSecret(): string { + return randomBytes(32).toString("hex") +} + +export function hashInstallSecret(secret: string): string { + return createHash("sha256").update(secret).digest("hex") +} + +function credentialsFromStored(stored: FreeTierStore.Record | undefined): Credentials | undefined { + if (!stored?.apiKey || !stored.baseURL) return undefined + return { + apiKey: stored.apiKey, + baseURL: stored.baseURL, + expiresAt: stored.expiresAt, + installSecret: stored.installSecret, + ...(stored.rejected ? { rejected: true } : {}), + } +} + +export async function credentials(): Promise { + return credentialsFromStored(await FreeTierStore.read()) +} + +export async function hasStoredRegistrationState(): Promise { + return (await FreeTierStore.read()) !== undefined +} + +function expired(value: Credentials): boolean { + if (!value.expiresAt) return false + const timestamp = Date.parse(value.expiresAt) + return !Number.isFinite(timestamp) || timestamp <= Date.now() +} + +export async function credentialsForLoad(): Promise { + const stored = await credentials() + if (!stored || stored.baseURL !== gatewayUrl()) return undefined + // Provider discovery must remain read-only. Refreshing here would mint credentials without the + // current launch's explicit TUI disclosure/consent operation. + if (stored.rejected || expired(stored)) return undefined + return stored +} + +export async function isRegistered(): Promise { + return (await credentialsForLoad()) !== undefined +} + +/** + * Disconnect the managed provider without resetting the fair-use identity. + * + * The install secret never leaves this machine; registration sends only its SHA-256 hash. Keeping + * it across logout prevents the supported CLI flow from minting a fresh free-allowance principal. + */ +export async function logout(): Promise { + rejectedCredentials.clear() + await Flock.withLock(LOCK_KEY, async () => { + let stored: FreeTierStore.Record | undefined + try { + stored = await FreeTierStore.read() + } catch (error) { + if (!(error instanceof FreeTierStore.InvalidCredentialStoreError)) throw error + // A malformed record has no trustworthy identity or credential to preserve. Atomically + // replacing it still disconnects the provider and gives pending registrations a new nonce. + log.warn("replacing invalid Altimate Base credential record during logout", { error }) + } + await FreeTierStore.write({ + version: 1, + // A legacy-only logout may race the first managed registration before that registration has + // written its identity. Persisting one here gives the nonce a durable record in that case. + installSecret: stored?.installSecret ?? mintInstallSecret(), + // A pending registration captures the previous nonce before waiting for this same file lock. + // Rotating it makes that stale operation fail its post-lock check instead of reconnecting. + logoutNonce: randomBytes(16).toString("hex"), + }) + }) +} + +export function sanitizeCliVersion(raw: string): string { + const coerced = raw + .replace(/[^A-Za-z0-9._+-]/g, "-") + .replace(/^[^A-Za-z0-9]+/, "") + .slice(0, 32) + return coerced || "unknown" +} + +function describeRegistrationFailure(status: number): string { + if (status === 429) return "Too many Altimate Base registrations from this network right now. Try again later." + if (status === 503) return "Altimate Base is temporarily unavailable. Try again later." + return `Altimate Base registration failed (HTTP ${status}).` +} + +function sameOrigin(left: string, right: string): boolean { + try { + return new URL(left).origin === new URL(right).origin + } catch { + return false + } +} + +function safeOrigin(value: string): string { + try { + return new URL(value).origin + } catch { + return "" + } +} + +function credentialFingerprint(value: Pick): string { + return createHash("sha256").update(`${value.baseURL}\0${value.apiKey}`).digest("hex") +} + +function markCredentialRejectedInMemory(value: Pick): void { + const fingerprint = credentialFingerprint(value) + rejectedCredentials.delete(fingerprint) + rejectedCredentials.add(fingerprint) + while (rejectedCredentials.size > REJECTED_CREDENTIAL_LIMIT) { + const oldest = rejectedCredentials.keys().next().value + if (!oldest) break + rejectedCredentials.delete(oldest) + } +} + +function credentialWasRejected(value: Pick): boolean { + return rejectedCredentials.has(credentialFingerprint(value)) +} + +function clearRejectedCredentialInMemory(value: Pick): void { + rejectedCredentials.delete(credentialFingerprint(value)) + clearUnauthorizedCount(value) +} + +function countUnauthorized(value: Pick): number { + const fingerprint = credentialFingerprint(value) + const next = (unauthorizedCounts.get(fingerprint) ?? 0) + 1 + unauthorizedCounts.delete(fingerprint) + unauthorizedCounts.set(fingerprint, next) + while (unauthorizedCounts.size > REJECTED_CREDENTIAL_LIMIT) { + const oldest = unauthorizedCounts.keys().next().value + if (!oldest) break + unauthorizedCounts.delete(oldest) + } + return next +} + +function clearUnauthorizedCount(value: Pick): void { + unauthorizedCounts.delete(credentialFingerprint(value)) +} + +async function markCredentialRejected(value: Pick): Promise { + markCredentialRejectedInMemory(value) + if (countUnauthorized(value) < REJECTED_PERSIST_THRESHOLD) { + // Blocked for the rest of this process, but not disowned on disk: a relaunch retries the + // credential, so a transient gateway fault resolves itself without another disclosure. + log.warn("Altimate Base credential rejected once; not persisting yet") + return + } + await Flock.withLock(LOCK_KEY, async () => { + const stored = await FreeTierStore.read() + if (!stored?.apiKey || stored.apiKey !== value.apiKey || stored.baseURL !== value.baseURL || stored.rejected) return + await FreeTierStore.write({ ...stored, rejected: true }) + }).catch((error) => { + // The in-memory marker still prevents reuse in this process. Preserve the gateway's response + // instead of replacing it with a local persistence failure. + log.warn("failed to persist rejected Altimate Base credentials", { error }) + }) +} + +function registrationCancelled(): RegistrationError { + return new RegistrationError("Altimate Base setup was cancelled by logout. Reopen setup to connect again.", "cancelled") +} + +async function installSecretForRegistration(expectedLogoutNonce: string | undefined): Promise { + const stored = await FreeTierStore.read() + if (stored?.logoutNonce !== expectedLogoutNonce) throw registrationCancelled() + if (stored?.installSecret) return stored.installSecret + const installSecret = mintInstallSecret() + // Persist before the request so a lost response cannot mint another budget principal on retry. + await FreeTierStore.write({ + version: 1, + installSecret, + ...(expectedLogoutNonce ? { logoutNonce: expectedLogoutNonce } : {}), + }) + return installSecret +} + +async function registerOnce( + configuredGateway: string, + expectedLogoutNonce: string | undefined, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted() + const installSecret = await installSecretForRegistration(expectedLogoutNonce) + signal?.throwIfAborted() + let response: Response + try { + response = await fetch(`${configuredGateway}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + install_secret_hash: hashInstallSecret(installSecret), + cli_version: sanitizeCliVersion(Installation.VERSION), + }), + redirect: "error", + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(REGISTER_TIMEOUT_MS)]) + : AbortSignal.timeout(REGISTER_TIMEOUT_MS), + }) + } catch (error) { + log.warn("Altimate Base registration request failed", { error }) + throw new RegistrationError("Could not reach the Altimate Base gateway. Check your connection.", "network") + } + + if (!response.ok) { + log.warn("Altimate Base registration rejected", { status: response.status }) + throw new RegistrationError(describeRegistrationFailure(response.status), "http", response.status) + } + + const body = (await response.json().catch(() => undefined)) as + | { api_key?: unknown; base_url?: unknown; expires_at?: unknown; model?: unknown } + | undefined + const apiKey = typeof body?.api_key === "string" ? body.api_key.trim() : "" + const baseURL = typeof body?.base_url === "string" ? FreeTierUrl.normalizeGatewayUrl(body.base_url) : undefined + const expiresAtPresent = body?.expires_at !== undefined + const expiresAt = typeof body?.expires_at === "string" ? body.expires_at.trim() : undefined + const expiresAtTimestamp = expiresAt ? Date.parse(expiresAt) : undefined + if ( + !apiKey || + !baseURL || + baseURL !== configuredGateway || + (expiresAtPresent && + (!expiresAt || + expiresAtTimestamp === undefined || + !Number.isFinite(expiresAtTimestamp) || + expiresAtTimestamp <= Date.now())) || + (body?.model !== undefined && body.model !== MODEL_ID) + ) { + throw new RegistrationError("The Altimate Base gateway returned an unexpected response.", "response") + } + + const result: Credentials = { + apiKey, + baseURL, + installSecret, + ...(expiresAt ? { expiresAt } : {}), + } + await FreeTierStore.write({ + version: 1, + installSecret, + ...(expectedLogoutNonce ? { logoutNonce: expectedLogoutNonce } : {}), + apiKey, + baseURL, + ...(result.expiresAt ? { expiresAt: result.expiresAt } : {}), + }) + // registerOnce runs while LOCK_KEY is already held, so only touch the process-local cache here; + // the newly written record above has already cleared the persisted rejection marker. + clearRejectedCredentialInMemory(result) + return result +} + +/** + * Register only after redeeming a one-shot consent token. + * + * The token is checked here, before any network or storage effect, against the private consent + * authority this module claimed at load time (`redeemConsent`, see capability.ts) — so + * "registration requires an accepted disclosure" is enforced by this function itself rather than + * by the discipline of its callers. A caller cannot forge a token by constructing their own + * `ConsentCapabilityStore`: that class's `arm`/`consume` only ever validate against the instance + * you built, and the ONE instance this function actually checks is never exported — the only way + * to arm it is `FreeTierCapability.issueArmer()`, claimed once by the TUI worker's consent gate at + * boot. A future CLI, HTTP route, or plugin cannot register by importing this: it would have to + * obtain a token minted by that gate. Provider discovery and inference never call it. + */ +export async function registerAfterConsent( + token: string, + input: { signal?: AbortSignal } = {}, +): Promise { + if (!redeemConsent(token)) { + throw new RegistrationError("Altimate Base consent expired. Reopen setup and try again.", "cancelled") + } + const configuredGateway = gatewayUrl() + const dedupeKey = configuredGateway + const pending = inflight.get(dedupeKey) + if (pending) return pending + + const started = (async () => { + let expectedLogoutNonce: string | undefined + try { + expectedLogoutNonce = (await FreeTierStore.read())?.logoutNonce + } catch (error) { + if (!(error instanceof FreeTierStore.InvalidCredentialStoreError)) throw error + // The existing explicit-consent repair path below owns malformed records. + } + + return Flock.withLock(LOCK_KEY, async () => { + let fresh: Credentials | undefined + try { + const stored = await FreeTierStore.read() + if (stored?.logoutNonce !== expectedLogoutNonce) throw registrationCancelled() + fresh = credentialsFromStored(stored) + } catch (error) { + if (!(error instanceof FreeTierStore.InvalidCredentialStoreError)) throw error + // This path is reachable only after explicit disclosure acceptance. Repairing here keeps a + // truncated credential file from permanently bricking setup without silently erasing it + // during provider discovery. + log.warn("removing invalid Altimate Base credential record after explicit consent", { error }) + await FreeTierStore.remove() + } + if ( + fresh && + fresh.baseURL === configuredGateway && + !expired(fresh) && + !fresh.rejected && + !credentialWasRejected(fresh) + ) + return fresh + if (fresh && (fresh.rejected || credentialWasRejected(fresh))) { + log.info("rotating a rejected Altimate Base credential after explicit consent") + } + return registerOnce(configuredGateway, expectedLogoutNonce, input.signal) + }) + })().finally(() => { + if (inflight.get(dedupeKey) === started) inflight.delete(dedupeKey) + }) + inflight.set(dedupeKey, started) + return started +} + +function targetUrl(input: RequestInfo | URL): string { + return typeof input === "string" ? input : input instanceof URL ? input.href : input.url +} + +function isReplayable(input: RequestInfo | URL, body: BodyInit | null | undefined): boolean { + if (input instanceof Request && input.body) return false + return ( + body == null || + typeof body === "string" || + body instanceof Uint8Array || + body instanceof ArrayBuffer || + body instanceof URLSearchParams || + body instanceof Blob + ) +} + +function requestHeaders(input: RequestInfo | URL, init?: RequestInit): Headers { + const headers = new Headers(input instanceof Request ? input.headers : undefined) + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)) + return headers +} + +export async function authorizedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const initial = await credentialsForLoad() + if (!initial) throw new Error("Altimate Base credentials are unavailable. Set up the model again.") + + const target = targetUrl(input) + if (!sameOrigin(target, initial.baseURL)) { + log.error("blocked Altimate Base request to an unregistered origin", { + expected: safeOrigin(initial.baseURL), + actual: safeOrigin(target), + }) + throw new Error("Blocked an Altimate Base request to an unregistered gateway origin.") + } + + const send = (next: Credentials): Promise | undefined => { + if (!sameOrigin(target, next.baseURL)) return undefined + const headers = requestHeaders(input, init) + headers.set("Authorization", `Bearer ${next.apiKey}`) + return fetch(input, { ...init, headers, redirect: "manual" }) + } + + const active = initial + const response = await send(active)! + // A success cannot prove that a concurrent 401 was stale: the key may have + // been revoked after this request was authorized. Only explicit consent and + // registration rotate/clear rejected credentials, keeping the ordinary + // inference path lock-free after its initial credential read. + // + // It does, however, prove the credential is not dead right now, so the consecutive-401 counter + // resets. Only an unbroken run of 401s disowns a credential on disk. Any non-401 response — a + // 2xx, or a 429/413/503 the gateway would not return for a rejected key — is equally proof of + // life; gating the reset on `response.ok` let a 401 that happened to straddle an unrelated + // rate-limit or outage response still reach the persistence threshold. + if (response.status !== 401) { + clearUnauthorizedCount(active) + return response + } + await markCredentialRejected(active) + if (!isReplayable(input, init?.body)) return response + + // Another consented process may have rotated the key while this request was in flight. Reuse + // that already-persisted credential once, but never POST /register from the inference path. + const next = await credentialsForLoad().catch((error) => { + log.warn("failed to read a rotated Altimate Base credential", { error }) + return undefined + }) + if (!next || next.apiKey === active.apiKey) return response + const retried = send(next) + if (!retried) { + log.error("blocked rotated Altimate Base credentials for a different origin", { + expected: safeOrigin(initial.baseURL), + actual: safeOrigin(next.baseURL), + }) + return response + } + const retryResponse = await retried + // altimate_change — mirror the initial response's reset above: a non-401 retry is equally proof + // of life for the rotated credential, so a prior 401 recorded against it elsewhere must not + // survive to later cross the rejection threshold on its own. + if (retryResponse.status !== 401) { + clearUnauthorizedCount(next) + return retryResponse + } + await markCredentialRejected(next) + return retryResponse +} + +export function describeRateLimit( + input: { body?: string; retryAfter?: string }, +): { message: string; retryable: boolean } | undefined { + let parsed: { error?: { type?: unknown; message?: unknown }; type?: unknown } | undefined + try { + parsed = input.body ? JSON.parse(input.body) : undefined + } catch { + return undefined + } + const kind = typeof parsed?.error?.type === "string" ? parsed.error.type : parsed?.type + const detail = typeof parsed?.error?.message === "string" ? parsed.error.message : "" + if (kind === "throttling_error") { + if (/Limit type: tokens/.test(detail)) { + return { + message: + "This request is too large for Altimate Base's per-minute token limit. Start a new session or shorten the context, then try again.", + retryable: false, + } + } + const seconds = Number(input.retryAfter) + const wait = Number.isFinite(seconds) && seconds > 0 ? ` Try again in ${Math.ceil(seconds)}s.` : " Try again shortly." + return { message: `Too many requests to Altimate Base right now.${wait}`, retryable: true } + } + if (kind === "budget_exceeded") { + if (detail.includes("ExceededBudget: User=")) { + return { + message: "You've used today's free Altimate Base allowance. It resets tomorrow—switch models to keep going.", + retryable: false, + } + } + if (detail.includes("Budget has been exceeded")) { + return { + message: "Altimate Base has reached its shared daily limit. It resets tomorrow—switch models to keep going.", + retryable: false, + } + } + return { + message: "The daily Altimate Base limit has been reached. It resets tomorrow—switch models to keep going.", + retryable: false, + } + } + return undefined +} + +export function describeRequestTooLarge(body?: string): string | undefined { + type Inner = { code?: unknown; message?: unknown; provider_specific_fields?: { error?: Inner } } + let parsed: { error?: Inner } | undefined + try { + parsed = body ? JSON.parse(body) : undefined + } catch { + return undefined + } + const inner = parsed?.error?.provider_specific_fields?.error + if (parsed?.error?.code !== "request_too_large" && inner?.code !== "request_too_large") return undefined + const detail = + typeof parsed?.error?.message === "string" + ? parsed.error.message + : typeof inner?.message === "string" + ? inner.message + : "" + const sizes = detail.match(/Request is (\d+) bytes; the free tier limit is (\d+) bytes/) + const numbers = sizes + ? ` (${Math.round(Number(sizes[1]) / 1024)}KB against a ${Math.round(Number(sizes[2]) / 1024)}KB limit)` + : "" + return `This request is too large for Altimate Base${numbers}. Start a new session, or switch to another model for this task.` +} + +export * as FreeTier from "./client" diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts new file mode 100644 index 0000000000..cd36510d90 --- /dev/null +++ b/packages/opencode/src/altimate/free/consent.ts @@ -0,0 +1,59 @@ +import { FreeTier } from "./client" +import { FreeTierStore } from "./store" + +export type RegistrationResult = + | { ok: true } + | { + ok: false + result: "network" | "rate_limited" | "unavailable" | "error" + message: string + } + +export function createRegistrationConsentGate(input: { + /** Arms the one-shot proof `register` will later be asked to redeem. */ + arm: (token: string) => void + /** Receives the bare token; must itself verify + consume proof of accepted disclosure. */ + register: (token: string) => Promise + onUnexpectedError?: (error: unknown) => void +}) { + return { + setToken(value: { token: string }): void { + input.arm(value.token) + }, + async register(value: { token: string }): Promise { + try { + await input.register(value.token) + return { ok: true } + } catch (error) { + if (error instanceof FreeTier.RegistrationError && error.kind === "cancelled") { + return { ok: false, result: "error", message: error.message } + } + if (error instanceof FreeTier.RegistrationError) { + return { + ok: false, + result: + error.status === 429 + ? "rate_limited" + : error.status === 503 + ? "unavailable" + : error.kind === "network" + ? "network" + : "error", + message: error.message, + } + } + if (error instanceof FreeTier.ConfigurationError || error instanceof FreeTierStore.InvalidCredentialStoreError) { + return { ok: false, result: "error", message: error.message } + } + input.onUnexpectedError?.(error) + return { + ok: false, + result: "error", + message: "Could not set up Altimate Base. Try again, or pick another provider.", + } + } + }, + } +} + +export * as FreeTierConsent from "./consent" diff --git a/packages/opencode/src/altimate/free/store.ts b/packages/opencode/src/altimate/free/store.ts new file mode 100644 index 0000000000..dd9575701d --- /dev/null +++ b/packages/opencode/src/altimate/free/store.ts @@ -0,0 +1,116 @@ +import { randomBytes } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import { Global } from "../../global" + +export interface Record { + version: 1 + installSecret: string + logoutNonce?: string + apiKey?: string + baseURL?: string + expiresAt?: string + rejected?: boolean +} + +export class InvalidCredentialStoreError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = "AltimateBaseInvalidCredentialStoreError" + } +} + +export function credentialPath(): string { + return path.join(Global.Path.data, "altimate-base.json") +} + +function isEnoent(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT" +} + +function parse(value: unknown): Record { + if (!value || typeof value !== "object") throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") + const input = value as { [key: string]: unknown } + if (input.version !== 1 || typeof input.installSecret !== "string" || !input.installSecret) { + throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") + } + for (const field of ["logoutNonce", "apiKey", "baseURL", "expiresAt"] as const) { + if (input[field] !== undefined && typeof input[field] !== "string") { + throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") + } + } + if (input.rejected !== undefined && typeof input.rejected !== "boolean") { + throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") + } + const apiKey = typeof input.apiKey === "string" ? input.apiKey : undefined + const baseURL = typeof input.baseURL === "string" ? input.baseURL : undefined + const expiresAt = typeof input.expiresAt === "string" ? input.expiresAt : undefined + const logoutNonce = typeof input.logoutNonce === "string" ? input.logoutNonce : undefined + return { + version: 1, + installSecret: input.installSecret, + ...(logoutNonce ? { logoutNonce } : {}), + ...(apiKey ? { apiKey } : {}), + ...(baseURL ? { baseURL } : {}), + ...(expiresAt ? { expiresAt } : {}), + ...(input.rejected === true ? { rejected: true } : {}), + } +} + +export async function read(): Promise { + let contents: string + try { + contents = await fs.readFile(credentialPath(), "utf8") + } catch (error) { + if (isEnoent(error)) return undefined + throw error + } + try { + return parse(JSON.parse(contents)) + } catch (error) { + if (error instanceof InvalidCredentialStoreError) throw error + throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.", { cause: error }) + } +} + +/** + * Replace the credential record atomically. The temporary file is created with 0600 before any + * secret bytes are written, then synced and renamed in the same directory. + */ +export async function write(record: Record): Promise { + const target = credentialPath() + const directory = path.dirname(target) + await fs.mkdir(directory, { recursive: true, mode: 0o700 }) + const temporary = `${target}.${process.pid}.${randomBytes(8).toString("hex")}.tmp` + let handle: fs.FileHandle | undefined + let ownsTemporary = false + try { + handle = await fs.open(temporary, "wx", 0o600) + ownsTemporary = true + await handle.writeFile(JSON.stringify(parse(record), null, 2) + "\n", "utf8") + await handle.sync() + await handle.chmod(0o600) + await handle.close() + handle = undefined + await fs.rename(temporary, target) + await fs.chmod(target, 0o600) + + // Persist the rename when the platform supports syncing a directory. Some Windows filesystems + // reject opening directories; the file itself is already synced in that case. + const parent = await fs.open(directory, "r").catch(() => undefined) + if (parent) { + await parent.sync().catch(() => {}) + await parent.close().catch(() => {}) + } + } catch (error) { + await handle?.close().catch(() => {}) + if (ownsTemporary) await fs.rm(temporary, { force: true }).catch(() => {}) + throw error + } +} + +export async function remove(): Promise { + await fs.rm(credentialPath(), { force: true }) +} + +export * as FreeTierStore from "./store" diff --git a/packages/opencode/src/altimate/free/url.ts b/packages/opencode/src/altimate/free/url.ts new file mode 100644 index 0000000000..3681a555a5 --- /dev/null +++ b/packages/opencode/src/altimate/free/url.ts @@ -0,0 +1,16 @@ +/** Normalize a credential-bearing gateway endpoint. HTTP, userinfo, and URL suffixes are rejected. */ +export function normalizeGatewayUrl(value: string): string | undefined { + const raw = value.trim() + // URL.search/hash are empty for bare trailing delimiters, so reject the source delimiters too. + if (!raw || raw.includes("?") || raw.includes("#")) return undefined + try { + const url = new URL(raw) + if (url.protocol !== "https:") return undefined + if (url.username || url.password || url.search || url.hash) return undefined + return url.toString().replace(/\/+$/, "") + } catch { + return undefined + } +} + +export * as FreeTierUrl from "./url" diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 0528c0a825..ffa865375e 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -820,7 +820,7 @@ export namespace Telemetry { timestamp: number session_id: string /** the picker mounts from several paths — without this the event over-counts first runs */ - trigger: "first_run" | "connect_command" | "big_pickle_back" | "prompt_gate" + trigger: "first_run" | "connect_command" | "altimate_base_back" | "prompt_gate" } | { type: "provider_selected" @@ -829,7 +829,7 @@ export namespace Telemetry { /** `search_all` means the user opened the full catalogue; the provider they then chose * arrives as a second event with `via_search`. `other` is any provider outside the * curated five. */ - provider: "altimate_gateway" | "anthropic" | "openai" | "google" | "big_pickle" | "search_all" | "other" + provider: "altimate_gateway" | "altimate_base" | "anthropic" | "openai" | "google" | "search_all" | "other" /** Raw provider id, but ONLY for publicly-known providers (see KNOWN_PROVIDER_IDS). * A user-defined provider in opencode.json can be named after their company, so * anything unrecognised is reported as `other` with this omitted. */ @@ -839,17 +839,23 @@ export namespace Telemetry { via_search?: boolean } | { - type: "big_pickle_confirm_shown" + type: "altimate_base_confirm_shown" timestamp: number session_id: string origin: "welcome" | "model" } | { - type: "big_pickle_choice" + type: "altimate_base_choice" timestamp: number session_id: string choice: "accept" | "cancel" } + | { + type: "altimate_base_register_result" + timestamp: number + session_id: string + result: "success" | "rate_limited" | "unavailable" | "network" | "error" + } | { type: "gateway_device_code_issued" timestamp: number @@ -1016,6 +1022,7 @@ export namespace Telemetry { // not on this list is reported as `other` with no raw value attached. const KNOWN_PROVIDER_IDS = new Set([ "altimate-backend", + "altimate-free", "anthropic", "openai", "google", @@ -1048,6 +1055,7 @@ export namespace Telemetry { // this function exists to enforce. const CURATED_PROVIDER_ENUM: Record = Object.assign(Object.create(null), { "altimate-backend": "altimate_gateway", + "altimate-free": "altimate_base", anthropic: "anthropic", openai: "openai", google: "google", @@ -1059,7 +1067,6 @@ export namespace Telemetry { providerID: string, modelID?: string, ): { provider: string; provider_id?: string } { - if (providerID === "opencode" && modelID === "big-pickle") return { provider: "big_pickle", provider_id: providerID } const curated = CURATED_PROVIDER_ENUM[providerID] if (curated) return { provider: curated, provider_id: providerID } return KNOWN_PROVIDER_IDS.has(providerID) ? { provider: "other", provider_id: providerID } : { provider: "other" } diff --git a/packages/opencode/src/altimate/telemetry/onboarding.ts b/packages/opencode/src/altimate/telemetry/onboarding.ts index 79a405e6ff..1b8f9a492b 100644 --- a/packages/opencode/src/altimate/telemetry/onboarding.ts +++ b/packages/opencode/src/altimate/telemetry/onboarding.ts @@ -30,7 +30,7 @@ export const ONBOARDING_STAGES = [ "started", "model_picker", "provider_setup", - "big_pickle_confirm", + "altimate_base_confirm", "gateway_auth", // NOTE: reaching this stage means the run completed, and emitAbandonedIfIncomplete() returns // early on `completed`. So "connected" is a valid funnel position but never a `last_stage` on @@ -48,8 +48,9 @@ type OnboardingEventInput = Extract< | "onboarding_started" | "model_picker_shown" | "provider_selected" - | "big_pickle_confirm_shown" - | "big_pickle_choice" + | "altimate_base_confirm_shown" + | "altimate_base_choice" + | "altimate_base_register_result" | "gateway_device_code_issued" | "gateway_auth_completed" | "gateway_auth_failed" @@ -91,7 +92,7 @@ const STAGE_FOR_EVENT: Partial = Object.entries(yield* Effect.orDie(authSvc.all())) + // altimate_change start — integrate the dedicated managed Base store with normal provider logout + const requestedProvider = args.provider?.toLowerCase() + const requestsAltimateBase = + requestedProvider === FreeTier.PROVIDER_ID || + requestedProvider === FreeTier.MODEL_ID || + requestedProvider === "altimate base" + const hasAltimateBaseCredential = yield* Effect.tryPromise(() => FreeTier.credentials()).pipe( + Effect.map((value) => value !== undefined), + // Keep malformed credential state visible so logout reports the storage error instead of + // silently claiming there is nothing configured. + Effect.orElseSucceed(() => true), + ) + const hasAltimateBaseState = requestsAltimateBase + ? yield* Effect.tryPromise(() => FreeTier.hasStoredRegistrationState()).pipe( + Effect.orElseSucceed(() => true), + ) + : false yield* Prompt.intro("Remove credential") - if (credentials.length === 0) { + const database = yield* modelsDev.get() + const hasLegacyAltimateBaseCredential = credentials.some(([key]) => key === FreeTier.PROVIDER_ID) + const options = credentials + .filter(([key]) => key !== FreeTier.PROVIDER_ID) + .map(([key, value]) => ({ + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", + value: key, + })) + if (hasAltimateBaseCredential || hasLegacyAltimateBaseCredential || hasAltimateBaseState) { + options.push({ + label: "Altimate Base" + UI.Style.TEXT_DIM + " (managed)", + value: FreeTier.PROVIDER_ID, + }) + } + if (options.length === 0) { yield* Prompt.log.error("No credentials found") return } - const database = yield* modelsDev.get() - const options = credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })) const provider = args.provider ? options.find( (option) => option.value === args.provider || - database[option.value]?.name?.toLowerCase() === args.provider?.toLowerCase(), + database[option.value]?.name?.toLowerCase() === requestedProvider || + (option.value === FreeTier.PROVIDER_ID && + (requestedProvider === FreeTier.MODEL_ID || requestedProvider === "altimate base")), )?.value : yield* promptValue( yield* Prompt.autocomplete({ @@ -602,6 +631,14 @@ export const ProvidersLogoutCommand = effectCmd({ }), ) if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) + if (provider === FreeTier.PROVIDER_ID) { + yield* cliTry("Failed to remove Altimate Base credential: ", () => FreeTier.logout()) + // Remove any stale entry created by pre-managed Base builds without touching other providers. + yield* Effect.orDie(authSvc.remove(FreeTier.PROVIDER_ID)) + yield* Prompt.outro("Logout successful") + return + } + // altimate_change end yield* Effect.orDie(authSvc.remove(provider)) yield* Prompt.outro("Logout successful") }), diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index fb22e17251..acbc5b7ec0 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -1,6 +1,8 @@ import { cmd } from "@/cli/cmd/cmd" import { Rpc } from "@/util/rpc" import { type rpc } from "../tui/worker" +// altimate_change — mint a short-lived capability for each accepted Base registration attempt +import { randomBytes } from "node:crypto" import path from "path" import { fileURLToPath } from "url" import { UI } from "@/cli/ui" @@ -174,7 +176,6 @@ export const TuiThreadCommand = cmd({ const reload = () => { client.call("reload", undefined).catch(() => {}) } - process.on("SIGUSR2", reload) let stopped = false const stop = async () => { @@ -190,6 +191,8 @@ export const TuiThreadCommand = cmd({ // altimate_change start — upstream_fix: clean up TUI worker after failed --session validation try { + process.on("SIGUSR2", reload) + const prompt = await input(args.prompt) const config = await TuiConfig.get() @@ -244,6 +247,14 @@ export const TuiThreadCommand = cmd({ }, config, pluginHost: createLegacyTuiPluginHost(), + // Keep Base registration on the private worker RPC even when the TUI itself is + // connected to an externally bound HTTP server. The token is minted only when the + // accepted disclosure invokes this host operation, then consumed once in the worker. + altimateBaseRegistration: async () => { + const token = randomBytes(32).toString("hex") + await client.call("setAltimateBaseConsentToken", { token }) + return client.call("registerAltimateBase", { token }) + }, // altimate_change — onboarding funnel seam. Deliberately a single-line marker, not a // start/end pair: this sits inside the "clean up TUI worker after failed --session // validation" region, and a nested closing marker truncates the block that @@ -310,7 +321,7 @@ export const TuiThreadCommand = cmd({ } finally { await stop() } - // altimate_change end + // altimate_change end — upstream_fix: clean up TUI worker after failed --session validation } finally { try { unguard?.() diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 8d4dd58e45..d32f93a9b4 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -27,6 +27,11 @@ import { Instance } from "@/project/instance" // altimate_change — onboarding telemetry: flush this thread's buffer in rpc.shutdown() import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" +// altimate_change start — register Altimate Base only across the private parent/worker RPC boundary +import { FreeTier } from "@/altimate/free/client" +import { FreeTierConsent } from "@/altimate/free/consent" +import { FreeTierCapability } from "@/altimate/free/capability" +// altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling // is enforced by the compiler rather than by a comment. @@ -62,8 +67,25 @@ GlobalBus.on("event", (event) => { }) let server: Awaited> | undefined +// altimate_change start — worker-local, expiring capabilities gate every registration mutation. +// `issueArmer()` can succeed exactly once per process; this is that one legitimate call — see +// capability.ts for why that makes the resulting token unforgeable by any other in-process code. +const altimateBaseRegistration = FreeTierConsent.createRegistrationConsentGate({ + arm: FreeTierCapability.issueArmer(), + register: (token) => FreeTier.registerAfterConsent(token), + onUnexpectedError: (error) => console.error("[altimate-base] registration failed", error), +}) +// altimate_change end export const rpc = { + // altimate_change start — install and consume a private capability only after disclosure acceptance + setAltimateBaseConsentToken(input: { token: string }) { + altimateBaseRegistration.setToken(input) + }, + async registerAltimateBase(input: { token: string }) { + return altimateBaseRegistration.register(input) + }, + // altimate_change end async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } const auth = ServerAuth.header() diff --git a/packages/opencode/src/mcp/discover.ts b/packages/opencode/src/mcp/discover.ts index e0d32dd9f5..cb45af672f 100644 --- a/packages/opencode/src/mcp/discover.ts +++ b/packages/opencode/src/mcp/discover.ts @@ -3,9 +3,9 @@ import path from "path" import { parse as parseJsonc } from "jsonc-parser" import { Log } from "../util/log" import { Filesystem } from "../util/filesystem" -import { Glob } from "../util/glob" import { ConfigPaths } from "../config/paths" import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" +import { DiscoveryFiles } from "./discovery-files" const log = Log.create({ service: "mcp.discover" }) @@ -450,67 +450,51 @@ export async function discoverExternalMcp(projectDir: string): Promise<{ // dedup is deterministic and keeps the historical .vscode > .cursor > copilot order // (a plain alphabetical sort would let .cursor override .vscode). const IDE_PRECEDENCE = [".vscode/mcp.json", ".cursor/mcp.json", ".github/copilot/mcp.json"] - const toRel = (abs: string) => path.relative(projectDir, abs).split(path.sep).join("/") - let mcpJsonFiles: string[] = [] + let mcpJsonFiles: DiscoveryFiles.ProjectMcpFile[] = [] try { - // altimate_change start — prune dependency/build trees during traversal. - // Filtering results after an unrestricted `**/mcp.json` walk still reads - // every directory in the project: on a monorepo with node_modules present - // that costs ~6 CPU-seconds per invocation, spread across the whole runtime - // I/O thread pool. The post-filter stays as defence in depth. - const IGNORE_GLOBS = [...Glob.DEFAULT_IGNORE] - const scanned = ( - await Glob.scan("**/mcp.json", { - cwd: projectDir, - absolute: true, - dot: true, - ignore: IGNORE_GLOBS, - }) - ).filter((abs) => { - const rel = toRel(abs) - return !IGNORE_GLOBS.some((pattern) => Glob.match(pattern, rel)) - }) - // altimate_change end - const rank = (abs: string) => { - const i = IDE_PRECEDENCE.indexOf(toRel(abs)) + const scanned = await DiscoveryFiles.scanProjectMcpJsonFiles(projectDir) + const rank = (file: DiscoveryFiles.ProjectMcpFile) => { + const i = IDE_PRECEDENCE.indexOf(file.relative) return i === -1 ? IDE_PRECEDENCE.length : i } mcpJsonFiles = scanned.sort((a, b) => { const ra = rank(a) const rb = rank(b) if (ra !== rb) return ra - rb - const relA = toRel(a) - const relB = toRel(b) - return relA < relB ? -1 : relA > relB ? 1 : 0 + return a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0 }) } catch { log.warn("mcp.json glob scan failed", { cwd: projectDir }) } for (const file of mcpJsonFiles) { - const parsed = await readJsonSafe(file) + const parsed = await readJsonSafe(file.path) if (!parsed || typeof parsed !== "object") continue - const label = toRel(file) || path.basename(file) + const label = file.relative addServersFromFile(mergeServerKeys(parsed), label, result, contributingSources, true) } // Non-"mcp.json" config files (not matched by the glob above), in project and/or home. for (const source of SOURCES) { - const dirs: Array<{ dir: string; label: string }> = [] + const dirs: Array<{ dir: string; label: string; projectScoped: boolean }> = [] if (source.scope === "project" || source.scope === "both") { - dirs.push({ dir: projectDir, label: source.file }) + dirs.push({ dir: projectDir, label: source.file, projectScoped: true }) } if ((source.scope === "home" || source.scope === "both") && projectDir !== homedir) { - dirs.push({ dir: homedir, label: `~/${source.file}` }) + dirs.push({ dir: homedir, label: `~/${source.file}`, projectScoped: false }) } - for (const { dir, label } of dirs) { - const filePath = path.join(dir, source.file) + for (const { dir, label, projectScoped } of dirs) { + const candidate = path.join(dir, source.file) + const resolved = projectScoped + ? await DiscoveryFiles.resolveProjectDiscoveryFile(projectDir, candidate) + : undefined + if (projectScoped && !resolved) continue + const filePath = resolved?.path ?? candidate const parsed = await readJsonSafe(filePath) if (!parsed || typeof parsed !== "object") continue - const isProjectScoped = dir === projectDir const servers = parsed[source.key] - addServersFromFile(servers, label, result, contributingSources, isProjectScoped) + addServersFromFile(servers, label, result, contributingSources, projectScoped) } } diff --git a/packages/opencode/src/mcp/discovery-files.ts b/packages/opencode/src/mcp/discovery-files.ts new file mode 100644 index 0000000000..f378ef2538 --- /dev/null +++ b/packages/opencode/src/mcp/discovery-files.ts @@ -0,0 +1,68 @@ +import { realpath } from "fs/promises" +import path from "path" +import { Glob } from "@opencode-ai/core/util/glob" + +export interface ProjectMcpFile { + /** Canonical path used for reading, after resolving any symlink. */ + path: string + /** Authored path relative to the project, retained for labels and precedence. */ + relative: string +} + +function relativeProjectPath(root: string, file: string): string | undefined { + const relative = path.relative(root, file) + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return undefined + } + return relative.split(path.sep).join("/") +} + +function isIgnored(relative: string): boolean { + return Glob.DEFAULT_IGNORE.some((pattern) => Glob.match(pattern, relative)) +} + +/** + * Resolve a project discovery file without allowing a symlink alias to escape + * the project or disguise a dependency/build artifact as authored config. + */ +export async function resolveProjectDiscoveryFile( + projectDir: string, + candidate: string, +): Promise { + try { + const lexicalRoot = path.resolve(projectDir) + const lexicalPath = path.resolve(candidate) + const lexicalRelative = relativeProjectPath(lexicalRoot, lexicalPath) + if (!lexicalRelative || isIgnored(lexicalRelative)) return undefined + + const [canonicalRoot, canonicalPath] = await Promise.all([realpath(lexicalRoot), realpath(lexicalPath)]) + const canonicalRelative = relativeProjectPath(canonicalRoot, canonicalPath) + if (!canonicalRelative || isIgnored(canonicalRelative)) return undefined + + return { path: canonicalPath, relative: lexicalRelative } + } catch { + return undefined + } +} + +/** + * Find authored mcp.json files while pruning dependency/build trees and then + * checking the canonical target of every match. The canonical check is the + * security boundary: glob ignores operate on aliases and cannot by themselves + * detect `.vscode/mcp.json -> node_modules/pkg/mcp.json`. + */ +export async function scanProjectMcpJsonFiles(projectDir: string): Promise { + const paths = await Glob.scan("**/mcp.json", { + cwd: projectDir, + absolute: true, + dot: true, + ignore: [...Glob.DEFAULT_IGNORE], + }) + + const files = await Promise.all(paths.map((candidate) => resolveProjectDiscoveryFile(projectDir, candidate))) + return files + .filter((file): file is ProjectMcpFile => file !== undefined) + .sort((a, b) => (a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0)) +} + +export * as DiscoveryFiles from "./discovery-files" diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index d1c2b9e171..1de0f4ceb0 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -2,6 +2,9 @@ import { APICallError } from "ai" import { STATUS_CODES } from "http" import { iife } from "@/util/iife" import type { ProviderID } from "./schema" +// altimate_change start — translate managed Altimate Base gateway errors +import { FreeTier } from "@/altimate/free/client" +// altimate_change end export namespace ProviderError { // altimate_change start — restore upstream v1.17.9 error classes dropped during @@ -326,6 +329,23 @@ export namespace ProviderError { // Check responseBody for context_length_exceeded code (e.g., OpenAI-style errors) const bodyParsed = json(input.error.responseBody) const codeFromBody = bodyParsed?.error?.code + // altimate_change start — distinguish the gateway byte cap from context overflow + // The gateway's fixed request-byte cap is not a context overflow. Retrying compaction can + // never help when system instructions and tool schemas alone exceed it. + if (String(input.providerID) === FreeTier.PROVIDER_ID && input.error.statusCode === 413) { + const described = FreeTier.describeRequestTooLarge(input.error.responseBody) + if (described) { + return { + type: "api_error", + message: described, + statusCode: 413, + isRetryable: false, + responseHeaders: input.error.responseHeaders, + metadata: input.error.url ? { url: maskInternalHost(input.error.url) } : undefined, + } + } + } + // altimate_change end if (isOverflow(m) || input.error.statusCode === 413 || codeFromBody === "context_length_exceeded") { return { type: "context_overflow", @@ -336,6 +356,25 @@ export namespace ProviderError { } } + // altimate_change start — surface Altimate Base quota and burst limits without leaking internals + if (String(input.providerID) === FreeTier.PROVIDER_ID && input.error.statusCode === 429) { + const described = FreeTier.describeRateLimit({ + body: input.error.responseBody, + retryAfter: input.error.responseHeaders?.["retry-after"], + }) + if (described) { + return { + type: "api_error", + message: described.message, + statusCode: 429, + isRetryable: described.retryable, + responseHeaders: input.error.responseHeaders, + metadata: input.error.url ? { url: maskInternalHost(input.error.url) } : undefined, + } + } + } + // altimate_change end + // altimate_change start — append a `models` discoverability hint when the // error code is model_not_found. Pairs with the retry-storm carve-out in // isOpenAiErrorRetryable so the user sees the hint on the first attempt diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index a956a38559..ea35e36f38 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -29,6 +29,9 @@ import { Global } from "../global" import path from "path" import { Filesystem } from "../util/filesystem" import { AltimateApi } from "../altimate/api/client" +// altimate_change start — managed Altimate Base provider and credential boundary +import { FreeTier } from "../altimate/free/client" +// altimate_change end // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -376,6 +379,23 @@ export namespace Provider { } return { autoload: false } }, + "altimate-free": async () => { + const creds = await FreeTier.credentialsForLoad().catch((error) => { + log.error("failed to read Altimate Base credentials", { error }) + return undefined + }) + if (!creds) return { autoload: false } + return { + autoload: true, + options: { + baseURL: `${creds.baseURL}/v1`, + // The real managed credential stays in the dedicated store and is injected only by + // authorizedFetch. Provider options are serialized by public provider APIs. + apiKey: FreeTier.MANAGED_API_KEY_PLACEHOLDER, + fetch: FreeTier.authorizedFetch, + }, + } + }, // altimate_change end openai: async () => { return { @@ -1159,7 +1179,12 @@ export namespace Provider { log.info("init") - const configProviders = Object.entries(config.provider ?? {}) + // altimate_change start — keep project config from steering the managed provider + // This managed provider's SDK module, model, headers, and endpoint come only from the client + // and registration response. A project config must never steer its stored credential. + const configProviders = Object.entries(config.provider ?? {}).filter(([id]) => id !== FreeTier.PROVIDER_ID) + const configProviderMap = Object.fromEntries(configProviders) + // altimate_change end // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot if (database["github-copilot"]) { @@ -1484,6 +1509,56 @@ export namespace Provider { } // altimate_change end + // altimate_change start — register the hosted model under the stable Altimate Base alias + // The hosted model behind the gateway's stable public model alias. Pinning this record keeps a + // models.dev collision from replacing the SDK module or endpoint that receives the free key. + const baseModels: Record = { + [FreeTier.MODEL_ID]: { + id: ModelID.make(FreeTier.MODEL_ID), + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + name: "Altimate Base", + // altimate_change — providerID is "altimate-free" (not "altimate-backend"), so this never + // reaches the family-based vendor switch in session/system.ts; prompt selection falls + // through to the api.id check there, and familyVendor() maps this value to no vendor either + // way. Purely descriptive metadata, so it does not need to name the underlying model. + family: "altimate", + api: { id: FreeTier.MODEL_ID, url: "", npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + // This is the offline/fallback value only — the gateway is the source of truth for what it + // actually serves and advertises. Keep it equal to the gateway's advertised + // {context: 131072, output: 65536} so the two never disagree; the served model natively + // supports a larger context window, so 131072 needs no scaling tricks on the gateway side. + // Output is a clean half of the context window (not the max the model could theoretically + // emit) because this is a reasoning+coding model — reasoning tokens count toward output — + // and 65536 still guarantees >=65536 input room while staying a real free-tier cost + // guardrail under the model's native ceiling. + limit: { context: 131_072, output: 65_536 }, + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "2026-08-29", + variants: {}, + }, + } + database[FreeTier.PROVIDER_ID] = { + id: ProviderID.make(FreeTier.PROVIDER_ID), + name: "Altimate", + source: "custom", + env: [], + options: {}, + models: baseModels, + } + // altimate_change end + function mergeProvider(providerID: ProviderID, provider: Partial) { const existing = providers[providerID] if (existing) { @@ -1625,6 +1700,9 @@ export namespace Provider { // load apikeys for (const [id, provider] of Object.entries(await Auth.all())) { + // altimate_change start — the managed provider is hydrated only from its dedicated credential store + if (id === FreeTier.PROVIDER_ID) continue + // altimate_change end const providerID = ProviderID.make(id) if (disabled.has(providerID)) continue if (provider.type === "api") { @@ -1684,7 +1762,9 @@ export namespace Provider { for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) { const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue + // altimate_change start — apply the same provider allowlist to managed custom loaders + if (!isProviderAllowed(providerID)) continue + // altimate_change end const data = database[providerID] if (!data) { log.error("Provider does not exist in model list " + providerID) @@ -1717,7 +1797,9 @@ export namespace Provider { continue } - const configProvider = config.provider?.[providerID] + // altimate_change start — use the sanitized config map that excludes Altimate Base + const configProvider = configProviderMap[providerID] + // altimate_change end for (const [modelID, model] of Object.entries(provider.models)) { model.api.id = model.api.id ?? model.id ?? modelID @@ -2052,7 +2134,9 @@ export namespace Provider { return undefined } - const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] + // altimate_change start — Altimate Base replaces Big Pickle in implicit model sorting + const priority = ["gpt-5", "claude-sonnet-4", "altimate-base", "gemini-3-pro"] + // altimate_change end export function sort(models: T[]) { return sortBy( models, @@ -2062,22 +2146,51 @@ export namespace Provider { ) } + // altimate_change start — discard malformed persisted model references before use + function isModelReference(model: unknown): model is { providerID: ProviderID; modelID: ModelID } { + if (!model || typeof model !== "object") return false + const value = model as Record + return typeof value.providerID === "string" && typeof value.modelID === "string" + } + // altimate_change end + export async function defaultModel() { const cfg = await Config.get() if (cfg.model) return parseModel(cfg.model) const providers = await list() + // altimate_change start — preserve explicit/recent precedence without bypassing managed consent + const configuredProviderEntries = Object.keys(cfg.provider ?? {}) + const hasProviderAllowlist = configuredProviderEntries.length > 0 + // A provider block is an allowlist for implicit choices. The managed + // provider remains consent-gated, so naming it cannot activate it; an + // explicit top-level `model` above remains authoritative. + const providerAllowed = (id: string) => + !hasProviderAllowlist || (id !== FreeTier.PROVIDER_ID && configuredProviderEntries.includes(id)) + const baseProviderID = ProviderID.make(FreeTier.PROVIDER_ID) + const baseModelID = ModelID.make(FreeTier.MODEL_ID) + const baseProvider = providers[baseProviderID] + const registeredBaseAvailable = Boolean(baseProvider?.models[baseModelID]) && !hasProviderAllowlist const recent = (await Filesystem.readJson<{ recent?: { providerID: ProviderID; modelID: ModelID }[] }>( path.join(Global.Path.state, "model.json"), ) - .then((x) => (Array.isArray(x.recent) ? x.recent : [])) + .then((x) => (Array.isArray(x.recent) ? x.recent.filter(isModelReference) : [])) .catch(() => [])) as { providerID: ProviderID; modelID: ModelID }[] for (const entry of recent) { + // A recent entry is the user's own last pick, so it is never rewritten here — not even a + // legacy Big Pickle one. The TUI owns the migration because it owns the disclosure, and + // `migrateLegacyDefault()` rewrites model.json on accept, so headless follows on the next + // launch. Migrating here instead would move a declining user to the request-logging tier + // with no prompt and no way to refuse. const provider = providers[entry.providerID] if (!provider) continue if (!provider.models[entry.modelID]) continue + // Keep legacy recent-model behavior unchanged for every other provider; + // only the consent-gated managed provider must not bypass this project. + if (entry.providerID === FreeTier.PROVIDER_ID && !providerAllowed(String(entry.providerID))) continue return { providerID: entry.providerID, modelID: entry.modelID } } + // altimate_change end // altimate_change start — default to altimate-backend when configured and no model chosen yet const altimateProviderID = ProviderID.make("altimate-backend") @@ -2085,7 +2198,7 @@ export namespace Provider { if ( altimateProvider && altimateProvider.models[ModelID.make("altimate-default")] && - (!cfg.provider || Object.keys(cfg.provider).includes(String(altimateProviderID))) + providerAllowed(String(altimateProviderID)) ) { // altimate_change start — log when altimate-backend auto-selected log.info("defaulting to altimate-backend/altimate-default (no model configured)") @@ -2097,14 +2210,31 @@ export namespace Provider { } // altimate_change end - const provider = Object.values(providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)) - if (!provider) throw new Error("no providers found") - const [model] = sort(Object.values(provider.models)) - if (!model) throw new Error("no models found") - return { - providerID: provider.id, - modelID: model.id, + // altimate_change start — select registered Altimate Base and never select Big Pickle implicitly + // Altimate Base owns the free fallback role that used to belong to Big Pickle, but only as a + // LAST resort. Anything the user has actually connected outranks the request-logging tier, so + // adding a paid key never silently routes prompts to the free gateway. A project provider + // block cannot force the managed model; an explicit `model` setting above remains + // authoritative. + // Base is excluded from the ordinary scan so it can only be reached by the last-resort branch + // below; otherwise it would win here whenever no provider block narrows the candidate list. + const candidates = Object.values(providers).filter( + (provider) => provider.id !== FreeTier.PROVIDER_ID && providerAllowed(provider.id), + ) + if (candidates.length === 0 && !registeredBaseAvailable) throw new Error("no providers found") + for (const provider of candidates) { + const model = sort(Object.values(provider.models)).find( + (candidate) => !(provider.id === "opencode" && candidate.id === "big-pickle"), + ) + if (model) return { providerID: provider.id, modelID: model.id } } + + if (registeredBaseAvailable) { + log.info("defaulting to altimate-free/altimate-base (no other connected model)") + return { providerID: baseProviderID, modelID: baseModelID } + } + throw new Error("no models found") + // altimate_change end } export function parseModel(model: string) { @@ -2171,6 +2301,9 @@ export namespace Provider { // imperative wrappers (list/getModel/getLanguage/defaultModel/...) remain exported // for the fork's synchronous callers. export interface Interface { + // altimate_change start — expose the full provider database to the public-info handler + readonly all: () => Effect.Effect> + // altimate_change end readonly list: () => Effect.Effect> readonly getProvider: (providerID: ProviderID) => Effect.Effect readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect @@ -2201,6 +2334,9 @@ export namespace Provider { export const layer = Layer.succeed( Service, Service.of({ + // altimate_change start — Effect wrapper for the full provider database + all: () => withLegacyInstance(() => all()), + // altimate_change end list: () => withLegacyInstance(() => list()), getProvider: (providerID) => withLegacyInstance(() => getProvider(providerID)), getModel: (providerID, modelID) => withLegacyInstance(() => getModel(providerID, modelID)), diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index e069d70284..ec25090aeb 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -42,3 +42,7 @@ export const ModelID = modelIdSchema.pipe( zod: z.string().pipe(z.custom()), })), ) + +// altimate_change start — expose the module through the repository's namespace projection convention +export * as ProviderSchema from "./schema" +// altimate_change end diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index dc3c6e7863..1a30a83a25 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -474,6 +474,10 @@ export namespace ProviderTransform { if (id.includes("north-mini-code")) return 1.0 // altimate_change end if (id.includes("qwen")) return 0.55 + // altimate_change start — the model served behind this stable alias needs the same tuning as + // the row above; the gateway does not force sampling params on its own. + if (id.includes("altimate-base")) return 0.55 + // altimate_change end if (id.includes("claude")) return undefined if (id.includes("gemini")) return 1.0 if (id.includes("glm-4.6")) return 1.0 @@ -492,6 +496,9 @@ export namespace ProviderTransform { export function topP(model: Provider.Model) { const id = model.id.toLowerCase() if (id.includes("qwen")) return 1 + // altimate_change start — same served-model reasoning as temperature() above. + if (id.includes("altimate-base")) return 1 + // altimate_change end if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { return 0.95 } @@ -696,7 +703,9 @@ export namespace ProviderTransform { id.includes("kimi") || id.includes("k2p") || id.includes("qwen") || - id.includes("big-pickle") + id.includes("big-pickle") || + // altimate_change — same served-model reasoning as temperature()/topP() above. + id.includes("altimate-base") ) return {} // altimate_change end diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 43a7485743..7370c05871 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -10,6 +10,10 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { ProviderAuthApiError } from "../groups/provider" import { ProviderV2 } from "@opencode-ai/core/provider" +// altimate_change start — advertise managed Altimate Base before credential consent +import { FreeTier } from "@/altimate/free/client" +import { ProviderSchema } from "@/provider/schema" +// altimate_change end function mapProviderAuthError(self: Effect.Effect) { return self.pipe( @@ -40,6 +44,9 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() const all = yield* ModelsDev.Service.use((s) => s.get()) + // altimate_change start — add managed model metadata without claiming a connected credential + const database = yield* provider.all() + // altimate_change end const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} @@ -47,12 +54,22 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value } const connected = yield* provider.list() + // altimate_change start — advertise enabled Altimate Base independently of connected providers + const managedBase = database[ProviderSchema.ProviderID.make(FreeTier.PROVIDER_ID)] + const managed = + managedBase && (enabled ? enabled.has(FreeTier.PROVIDER_ID) : true) && !disabled.has(FreeTier.PROVIDER_ID) + ? { [FreeTier.PROVIDER_ID]: managedBase } + : {} + // altimate_change end const providers = Object.assign( // altimate_change start — upstream_fix: widen readonly ModelsDev providers for Provider conversion // ModelsDev.Service yields a deeply-readonly Provider; fromModelsDevProvider only // reads it, so widen the readonly shape to the mutable signature it expects. mapValues(filtered, (item) => Provider.fromModelsDevProvider(item as Parameters[0])), // altimate_change end + // altimate_change start — merge managed metadata without adding it to connected + managed, + // altimate_change end connected, ) return { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 69b72088b5..4f85b10c5b 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -61,6 +61,20 @@ export namespace LLM { export type StreamOutput = StreamTextResult + // altimate_change start — Altimate Base gateway session-scoped abuse control + export function withManagedSessionHeaders( + providerID: string, + sessionID: string, + headers: Record, + ): Record { + if (providerID !== "altimate-free") return headers + return { + ...Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== "x-session-id")), + "X-Session-Id": sessionID, + } + } + // altimate_change end + export async function stream(input: StreamInput) { const l = log .clone() @@ -297,8 +311,8 @@ export namespace LLM { maxOutputTokens, // altimate_change end abortSignal: input.abort, - // altimate_change start — send the canonical headers used by the budget estimator - headers: requestHeaders, + // altimate_change start — send the canonical headers used by the budget estimator, bound to the current Altimate Base session + headers: withManagedSessionHeaders(input.model.providerID, input.sessionID, requestHeaders), // altimate_change end maxRetries: input.retries ?? 0, messages: [ diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index a3aaa89f3b..701628f5ee 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -5,11 +5,12 @@ import { describe, expect, test } from "bun:test" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" -import { ProviderID, ModelID } from "@/provider/schema" -import { defaultModelFromConfig } from "@/acp/service" +import { ProviderSchema } from "@/provider/schema" +import { ACPService } from "@/acp/service" +import { Directory } from "@/acp/directory" -const model = (providerID: ProviderID, id: string): Provider.Model => ({ - id: ModelID.make(id), +const model = (providerID: ProviderSchema.ProviderID, id: string): Provider.Model => ({ + id: ProviderSchema.ModelID.make(id), providerID, api: { id, url: "https://example.com", npm: "@ai-sdk/openai-compatible" }, name: id, @@ -32,14 +33,14 @@ const model = (providerID: ProviderID, id: string): Provider.Model => ({ }) const provider = (id: string, modelIDs: string[]): Provider.Info => { - const providerID = ProviderID.make(id) + const providerID = ProviderSchema.ProviderID.make(id) return { id: providerID, name: id, source: "config", env: [], options: {}, - models: Object.fromEntries(modelIDs.map((m) => [ModelID.make(m), model(providerID, m)])), + models: Object.fromEntries(modelIDs.map((m) => [ProviderSchema.ModelID.make(m), model(providerID, m)])), } as Provider.Info } @@ -48,7 +49,7 @@ const providers = (...infos: Provider.Info[]) => describe("ACP defaultModelFromConfig", () => { test("prefers altimate-backend/altimate-default when available and no model configured", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["big-pickle"])), ) @@ -58,34 +59,147 @@ describe("ACP defaultModelFromConfig", () => { }) }) - test("falls back to opencode when altimate-backend is not present", () => { - const result = defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"]))) + test("prefers registered Altimate Base when the paid gateway is not present", () => { + const result = ACPService.defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make("altimate-free"), + modelID: ModelV2.ID.make("altimate-base"), + }) + }) + + test("treats an empty provider object as unrestricted", () => { + const result = ACPService.defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), + {}, + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make("altimate-free"), + modelID: ModelV2.ID.make("altimate-base"), + }) + }) + + test("never chooses Big Pickle implicitly", () => { + expect( + ACPService.defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"]))), + ).toBeUndefined() + }) + + test("rejects a configured model that is not available", () => { + expect( + ACPService.defaultModelFromConfig("opencode/missing", providers(provider("opencode", ["big-pickle"]))), + ).toBeUndefined() + }) + + test("does not reintroduce Big Pickle through the ACP snapshot fallback", () => { + const snapshot = { + directory: "/tmp/acp-default-model-test", + providers: {}, + modelOptions: [ + { + providerID: ProviderV2.ID.make("opencode"), + providerName: "OpenCode", + modelID: ModelV2.ID.make("big-pickle"), + modelName: "Big Pickle", + }, + { + providerID: ProviderV2.ID.make("openai"), + providerName: "OpenAI", + modelID: ModelV2.ID.make("gpt-5"), + modelName: "GPT-5", + }, + ], + variantsByModel: {}, + availableModes: [], + defaultModeID: "build", + availableCommands: [], + } satisfies Directory.Snapshot + + expect(ACPService.selectDefaultModel(snapshot)).toEqual({ + providerID: ProviderV2.ID.make("openai"), + modelID: ModelV2.ID.make("gpt-5"), + }) + }) + + test("falls back to another OpenCode model when Altimate Base is not registered", () => { + const result = ACPService.defaultModelFromConfig( + undefined, + providers(provider("opencode", ["big-pickle", "gpt-5"])), + ) expect(result).toEqual({ providerID: ProviderV2.ID.make("opencode"), - modelID: ModelV2.ID.make("big-pickle"), + modelID: ModelV2.ID.make("gpt-5"), }) }) test("skips altimate-backend when an explicit provider allowlist excludes it", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( undefined, - providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["big-pickle"])), + providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["gpt-5"])), { opencode: {} }, ) expect(result?.providerID).toBe(ProviderV2.ID.make("opencode")) }) + test("a connected paid provider outranks registered Altimate Base", () => { + // Base logs requests, so it must never win over something the user actually connected. ACP has + // no recent-model list, so without this ordering a registered user with an Anthropic key would + // silently route every new session to the free logging tier. + const result = ACPService.defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("anthropic", ["claude-sonnet-4"])), + undefined, + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make("anthropic"), + modelID: ModelV2.ID.make("claude-sonnet-4"), + }) + }) + + test("falls back to Altimate Base when nothing else is connected", () => { + const result = ACPService.defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), + undefined, + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make("altimate-free"), + modelID: ModelV2.ID.make("altimate-base"), + }) + }) + + test("does not recover an excluded managed provider through the sorted fallback", () => { + const result = ACPService.defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), + { opencode: {} }, + ) + expect(result).toBeUndefined() + }) + + test("an Altimate Base-only provider block cannot force the managed provider", () => { + const result = ACPService.defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("openai", ["gpt-5"])), + { "altimate-free": {} }, + ) + expect(result).toBeUndefined() + }) + test("honors an explicit provider allowlist that includes altimate-backend", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( undefined, - providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["big-pickle"])), + providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["gpt-5"])), { "altimate-backend": {}, opencode: {} }, ) expect(result?.providerID).toBe(ProviderV2.ID.make("altimate-backend")) }) test("a valid configured model takes precedence over the altimate-backend default", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( "opencode/big-pickle", providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["big-pickle"])), ) @@ -94,5 +208,26 @@ describe("ACP defaultModelFromConfig", () => { modelID: ModelV2.ID.make("big-pickle"), }) }) + + test("returns no snapshot fallback when Big Pickle is the only option", () => { + const snapshot = { + directory: "/tmp/acp-big-pickle-only", + providers: {}, + modelOptions: [ + { + providerID: ProviderV2.ID.make("opencode"), + providerName: "OpenCode", + modelID: ModelV2.ID.make("big-pickle"), + modelName: "Big Pickle", + }, + ], + variantsByModel: {}, + availableModes: [], + defaultModeID: "build", + availableCommands: [], + } satisfies Directory.Snapshot + + expect(ACPService.selectDefaultModel(snapshot)).toBeUndefined() + }) }) // altimate_change end diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 8a72754f0e..48cc461527 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "bun:test" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { Effect, ManagedRuntime } from "effect" +// altimate_change start — give model-agnostic lifecycle tests a valid ACP model fixture +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +// altimate_change end import { ACPEvent } from "@/acp/event" import * as ACPService from "@/acp/service" import { Directory } from "@/acp/directory" @@ -366,6 +370,12 @@ describe("acp event routing", () => { modes: [], defaultModeID: "build", commands: [], + // altimate_change start — satisfy the fail-closed ACP model boundary + defaultModel: { + providerID: ProviderV2.ID.make("fixture"), + modelID: ModelV2.ID.make("fixture"), + }, + // altimate_change end }), ), refresh: () => @@ -376,6 +386,12 @@ describe("acp event routing", () => { modes: [], defaultModeID: "build", commands: [], + // altimate_change start — satisfy the fail-closed ACP model boundary + defaultModel: { + providerID: ProviderV2.ID.make("fixture"), + modelID: ModelV2.ID.make("fixture"), + }, + // altimate_change end }), ), variants: Directory.variants, @@ -471,6 +487,12 @@ describe("acp event routing", () => { modes: [], defaultModeID: "build", commands: [], + // altimate_change start — satisfy the fail-closed ACP model boundary + defaultModel: { + providerID: ProviderV2.ID.make("fixture"), + modelID: ModelV2.ID.make("fixture"), + }, + // altimate_change end }), ), refresh: () => @@ -481,6 +503,12 @@ describe("acp event routing", () => { modes: [], defaultModeID: "build", commands: [], + // altimate_change start — satisfy the fail-closed ACP model boundary + defaultModel: { + providerID: ProviderV2.ID.make("fixture"), + modelID: ModelV2.ID.make("fixture"), + }, + // altimate_change end }), ), variants: Directory.variants, diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 852ef81795..da9de3ed9a 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -148,7 +148,13 @@ const provider: Provider.Info = { describe("ACP service sessions", () => { const makeService = ( messages: readonly { info: unknown; parts: readonly unknown[] }[] = [], - options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> }, + options?: { + abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> + providers?: Provider.Info[] + providerConfig?: Record + configModel?: string + configFails?: boolean + }, ) => { const updates: SessionNotification[] = [] const mcpAdds: string[] = [] @@ -158,6 +164,7 @@ describe("ACP service sessions", () => { const commands: unknown[] = [] const summarizes: unknown[] = [] const usageUpdates: string[] = [] + const creates: unknown[] = [] const sessions = Array.from({ length: 102 }, (_, index) => ({ id: `ses_${index + 1}`, directory: index % 2 === 0 ? "/workspace" : "/other", @@ -166,8 +173,16 @@ describe("ACP service sessions", () => { })) const sdk = { config: { - providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), - get: () => Promise.resolve({ data: {} }), + providers: () => + Promise.resolve({ + data: { + providers: options?.providers ?? [provider], + }, + }), + get: () => + options?.configFails + ? Promise.reject(new Error("config unavailable")) + : Promise.resolve({ data: { provider: options?.providerConfig, model: options?.configModel } }), }, app: { agents: () => @@ -190,7 +205,10 @@ describe("ACP service sessions", () => { }), }, session: { - create: () => Promise.resolve({ data: { id: "ses_new" } }), + create: (input: unknown) => { + creates.push(input) + return Promise.resolve({ data: { id: "ses_new" } }) + }, get: () => Promise.resolve({ data: { id: "ses_loaded" } }), list: (input: { directory?: string }) => Promise.resolve({ @@ -272,6 +290,7 @@ describe("ACP service sessions", () => { commands, summarizes, usageUpdates, + creates, } } @@ -299,6 +318,280 @@ describe("ACP service sessions", () => { expect(mcpAdds).toEqual(["tools"]) }) + it("fails before creating a session when Big Pickle is the only implicit option", async () => { + const bigPickleProvider = { + ...provider, + id: ProviderID.make("opencode"), + name: "OpenCode", + models: { + [ModelID.make("big-pickle")]: { + ...provider.models[modelID], + id: ModelID.make("big-pickle"), + providerID: ProviderID.make("opencode"), + name: "Big Pickle", + }, + }, + } satisfies Provider.Info + const { service, creates } = makeService([], { providers: [bigPickleProvider] }) + + const failure = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }).pipe(Effect.flip)) + + expect(failure).toMatchObject({ + _tag: "ACPServiceFailureError", + safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.", + service: "model", + }) + expect(creates).toHaveLength(0) + }) + + it("fails before creating a session when the configured model is unavailable", async () => { + const bigPickleProvider = { + ...provider, + id: ProviderID.make("opencode"), + name: "OpenCode", + models: { + [ModelID.make("big-pickle")]: { + ...provider.models[modelID], + id: ModelID.make("big-pickle"), + providerID: ProviderID.make("opencode"), + name: "Big Pickle", + }, + }, + } satisfies Provider.Info + const { service, creates } = makeService([], { + providers: [bigPickleProvider], + configModel: "opencode/missing", + }) + + const failure = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }).pipe(Effect.flip)) + + expect(failure).toMatchObject({ + _tag: "ACPServiceFailureError", + safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.", + service: "model", + }) + expect(creates).toHaveLength(0) + }) + + it("keeps unrelated providers advertised when a provider block names only one of them", async () => { + // `config.provider` is a customization map — the docs show single-entry blocks setting apiKey + // or options. Treating it as a catalogue-wide allowlist hid every other authenticated provider + // from ACP clients and invalidated restored sessions pinned to them. + const other = { + ...provider, + id: ProviderID.make("anthropic"), + name: "Anthropic", + models: { + [ModelID.make("claude-sonnet-4")]: { + ...provider.models[modelID], + id: ModelID.make("claude-sonnet-4"), + providerID: ProviderID.make("anthropic"), + name: "Claude Sonnet 4", + }, + }, + } satisfies Provider.Info + const { service } = makeService([], { + providers: [provider, other], + providerConfig: { test: {} }, + }) + + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const models = flattenSelectOptions(select(result, "model")) + + expect(models.some((option) => option.value.includes("test-model"))).toBe(true) + expect(models.some((option) => option.value.includes("claude-sonnet-4"))).toBe(true) + }) + + it("does not advertise Altimate Base through an ACP snapshot excluded by a provider allowlist", async () => { + const baseProvider = { + ...provider, + id: ProviderID.make("altimate-free"), + name: "Altimate", + models: { + [ModelID.make("altimate-base")]: { + ...provider.models[modelID], + id: ModelID.make("altimate-base"), + providerID: ProviderID.make("altimate-free"), + name: "Altimate Base", + }, + }, + } satisfies Provider.Info + const { service } = makeService([], { + providers: [provider, baseProvider], + providerConfig: { test: {} }, + }) + + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const models = flattenSelectOptions(select(result, "model")) + + expect(models.some((option) => option.value.includes("altimate-base"))).toBe(false) + expect(models.some((option) => option.value.includes("test-model"))).toBe(true) + }) + + it("cannot enable Altimate Base merely by naming it in an ACP provider allowlist", async () => { + const baseProvider = { + ...provider, + id: ProviderID.make("altimate-free"), + name: "Altimate", + models: { + [ModelID.make("altimate-base")]: { + ...provider.models[modelID], + id: ModelID.make("altimate-base"), + providerID: ProviderID.make("altimate-free"), + name: "Altimate Base", + }, + }, + } satisfies Provider.Info + const { service } = makeService([], { + providers: [provider, baseProvider], + providerConfig: { test: {}, "altimate-free": {} }, + }) + + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const models = flattenSelectOptions(select(result, "model")) + + expect(models.some((option) => option.value.includes("altimate-base"))).toBe(false) + expect(models.some((option) => option.value.includes("test-model"))).toBe(true) + }) + + it("does not select or route to a configured Altimate Base model excluded by a provider allowlist", async () => { + // The bug this guards: `model: "altimate-free/altimate-base"` set alongside a provider + // allowlist that omits "altimate-free" got resolved against the UNFILTERED provider map even + // though the SAME allowlist correctly hid Altimate Base from the advertised catalogue (see the + // two tests above) — so ACP still selected and routed to it despite it being excluded. + const baseProvider = { + ...provider, + id: ProviderID.make("altimate-free"), + name: "Altimate", + models: { + [ModelID.make("altimate-base")]: { + ...provider.models[modelID], + id: ModelID.make("altimate-base"), + providerID: ProviderID.make("altimate-free"), + name: "Altimate Base", + }, + }, + } satisfies Provider.Info + const { service } = makeService([], { + providers: [provider, baseProvider], + providerConfig: { test: {} }, + configModel: "altimate-free/altimate-base", + }) + + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const models = flattenSelectOptions(select(result, "model")) + + expect(models.some((option) => option.value.includes("altimate-base"))).toBe(false) + expect(select(result, "model")?.currentValue).not.toContain("altimate-base") + // Falls through to the allowed provider's own catalogue instead of failing closed entirely. + expect(select(result, "model")?.currentValue).toBe("test/test-model") + }) + + it("fails closed for Altimate Base when the project config lookup fails", async () => { + const baseProvider = { + ...provider, + id: ProviderID.make("altimate-free"), + name: "Altimate", + models: { + [ModelID.make("altimate-base")]: { + ...provider.models[modelID], + id: ModelID.make("altimate-base"), + providerID: ProviderID.make("altimate-free"), + name: "Altimate Base", + }, + }, + } satisfies Provider.Info + const { service, creates } = makeService([], { + providers: [baseProvider], + configFails: true, + }) + + const failure = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }).pipe(Effect.flip)) + + expect(failure).toMatchObject({ + _tag: "ACPServiceFailureError", + safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.", + service: "model", + }) + expect(creates).toHaveLength(0) + }) + + it("fails before forking when no supported implicit model exists", async () => { + const bigPickleProvider = { + ...provider, + id: ProviderID.make("opencode"), + name: "OpenCode", + models: { + [ModelID.make("big-pickle")]: { + ...provider.models[modelID], + id: ModelID.make("big-pickle"), + providerID: ProviderID.make("opencode"), + name: "Big Pickle", + }, + }, + } satisfies Provider.Info + const { service, forks } = makeService([], { providers: [bigPickleProvider] }) + + const failure = await Effect.runPromise( + service.forkSession({ cwd: "/workspace", sessionId: "ses_parent", mcpServers: [] }).pipe(Effect.flip), + ) + + expect(failure).toMatchObject({ service: "model" }) + expect(forks).toHaveLength(0) + }) + + it("forks with the current default when the source model is no longer advertised", async () => { + const { service, forks } = makeService([ + { + info: { + role: "assistant", + providerID: "removed-provider", + modelID: "removed-model", + }, + parts: [], + }, + ]) + + const result = await Effect.runPromise( + service.forkSession({ cwd: "/workspace", sessionId: "ses_parent", mcpServers: [] }), + ) + + expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model") + expect(forks).toHaveLength(1) + }) + + it("drops a restored variant when load, resume, or fork falls back to a different model", async () => { + // `high` is valid for the fallback model, which makes this the important + // case: it still belongs to the removed model and must not leak across the + // model boundary merely because the variant names happen to match. + const { service } = makeService([ + { + info: { + role: "assistant", + providerID: "removed-provider", + modelID: "removed-model", + variant: "high", + }, + parts: [], + }, + ]) + + const loaded = await Effect.runPromise( + service.loadSession({ cwd: "/workspace", sessionId: "ses_fallback_load", mcpServers: [] }), + ) + const resumed = await Effect.runPromise( + service.resumeSession({ cwd: "/workspace", sessionId: "ses_fallback_resume", mcpServers: [] }), + ) + const forked = await Effect.runPromise( + service.forkSession({ cwd: "/workspace", sessionId: "ses_fallback_parent", mcpServers: [] }), + ) + + for (const result of [loaded, resumed, forked]) { + expect(select(result, "model")?.currentValue).toBe("test/test-model") + expect(select(result, "effort")?.currentValue).toBe("default") + } + }) + it("loads a session and restores model variant and mode from messages", async () => { const { service } = makeService([ { @@ -320,6 +613,26 @@ describe("ACP service sessions", () => { expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan") }) + it("drops a restored variant that is no longer advertised by the retained model", async () => { + const { service } = makeService([ + { + info: { + role: "assistant", + providerID: "test", + modelID: "test-model", + variant: "retired-effort", + }, + parts: [], + }, + ]) + + const result = await Effect.runPromise( + service.loadSession({ cwd: "/workspace", sessionId: "ses_invalid_variant", mcpServers: [] }), + ) + + expect(select(result, "effort")?.currentValue).toBe("default") + }) + it("replays loaded session transcript chunks", async () => { const { service, updates } = makeService([ { diff --git a/packages/opencode/test/altimate/_fixtures/altimate-base-harness.ts b/packages/opencode/test/altimate/_fixtures/altimate-base-harness.ts new file mode 100644 index 0000000000..45e704c770 --- /dev/null +++ b/packages/opencode/test/altimate/_fixtures/altimate-base-harness.ts @@ -0,0 +1,95 @@ +// Shared hermetic-test bootstrap for the Altimate Base e2e suites. +// +// Extracted from `test/altimate/altimate-base.test.ts`'s top-of-file isolated-environment +// pattern so every new suite (and that file) imports one implementation instead of +// re-copy-pasting it. See docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md, +// Deliverable 2, for the full design rationale. +import { randomBytes } from "node:crypto" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterAll } from "bun:test" +import { FreeTierCapability } from "../../../src/altimate/free/capability" + +const ISOLATED_ENV = [ + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "OPENCODE_TEST_HOME", +] as const + +/** + * Call once at module scope in each suite file, BEFORE importing `../../src/altimate/free/*` + * (the client reads Global.Path lazily per-call, but isolating env before any import keeps every + * suite file identical to how the existing altimate-base.test.ts already does it). + * + * Gives the file its own temp XDG/home tree so its credential store, config, and cache never + * touch a real user directory or another suite file's directory. Registers an `afterAll` that + * restores the previous env values and removes the temp tree. + */ +export function isolateAltimateBaseHome(prefix: string): string { + const original = Object.fromEntries(ISOLATED_ENV.map((key) => [key, process.env[key]])) + const home = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)) + process.env.XDG_DATA_HOME = path.join(home, "data") + process.env.XDG_CONFIG_HOME = path.join(home, "config") + process.env.XDG_CACHE_HOME = path.join(home, "cache") + process.env.XDG_STATE_HOME = path.join(home, "state") + process.env.OPENCODE_TEST_HOME = home + + afterAll(() => { + for (const key of ISOLATED_ENV) { + const value = original[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + fs.rmSync(home, { recursive: true, force: true }) + }) + return home +} + +/** + * Call in `beforeEach`: clears both current and legacy gateway env vars, then points the client + * at the fake gateway's URL. Mirrors `altimate-base.test.ts`'s existing `beforeEach` gateway-env + * reset so every suite starts from the same known configuration state. + */ +export function resetGatewayEnv(gatewayUrl: string): void { + delete process.env.ALTIMATE_BASE_GATEWAY_URL + delete process.env.ALTIMATE_FREE_GATEWAY_URL + process.env.ALTIMATE_BASE_GATEWAY_URL = gatewayUrl +} + +// `FreeTierCapability.issueArmer()` hands out the process's ONE consent-arming capability and +// throws on a second call — see `src/altimate/free/capability.ts`. In production that single call +// happens once, at TUI worker boot (`cli/tui/worker.ts`). Every Altimate Base e2e suite plays the +// role of that TUI host and needs the same capability, but `bun test test/altimate/` loads multiple +// suite files into ONE worker process, so if each file called `issueArmer()` itself at module +// scope, the second (and every subsequent) file to load would crash with "Altimate Base consent +// armer already issued for this process" — reproducible even with just the two pre-existing files +// (`altimate-base.test.ts` and `altimate-base-harness-smoke.test.ts`). +// +// This module-level singleton is the fix: it claims `issueArmer()` lazily, the first time any +// suite asks for a token, and caches the returned armer closure here. Bun caches modules per +// process, so every suite file that imports `consented()` from this file — regardless of how many +// separate test files load it — shares this exact module instance and therefore this exact cache. +// `issueArmer()` is still claimed exactly once per process; this adds no way to reset, re-claim, or +// otherwise bypass that one-shot guarantee. It is purely a shared cache in front of the single +// legitimate call, so the underlying security property (only one in-process caller can ever obtain +// the ability to arm the production consent authority) is unchanged. +let cachedArmer: ((token: string) => void) | undefined + +function armer(): (token: string) => void { + if (!cachedArmer) cachedArmer = FreeTierCapability.issueArmer() + return cachedArmer +} + +/** + * Mints a fresh one-shot consent token and arms it against the production consent authority, + * via the shared, process-wide armer above. Every suite should call this instead of claiming + * `FreeTierCapability.issueArmer()` itself. + */ +export function consented(): string { + const token = randomBytes(32).toString("hex") + armer()(token) + return token +} diff --git a/packages/opencode/test/altimate/_fixtures/fake-gateway.ts b/packages/opencode/test/altimate/_fixtures/fake-gateway.ts new file mode 100644 index 0000000000..55164b3fea --- /dev/null +++ b/packages/opencode/test/altimate/_fixtures/fake-gateway.ts @@ -0,0 +1,174 @@ +// Fetch-shaped fake for the two real Altimate Base gateway routes (`POST /register`, +// `POST /v1/chat/completions`). Installed via `spyOn(globalThis, "fetch")` — the same seam +// `test/altimate/altimate-base.test.ts` already uses — so every suite stays hermetic: no port +// binding, no real network, no external dependency. See +// docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md, Deliverable 2, for why an in-process +// fetch fake was chosen over a real local HTTP server. +import { spyOn } from "bun:test" + +export const GATEWAY_URL = "https://gateway.test" +export const MODEL_ID = "altimate-base" + +export interface RegisterCall { + url: string + installSecretHash: string + cliVersion: string +} +export interface ChatCall { + url: string + authorization: string | null + body: unknown +} + +export type RegisterMode = + | { kind: "ok"; apiKey?: string; expiresAt?: string | null; baseUrl?: string; model?: string } + | { kind: "http"; status: number; headers?: Record } + | { kind: "network" } + | { kind: "malformed-json" } + +export type ChatMode = + | { kind: "ok"; content?: string; status?: number } + | { kind: "throttle-tokens" } // 429 throttling_error, "Limit type: tokens" — non-retryable + | { kind: "throttle-burst"; retryAfterSeconds?: number } // 429 throttling_error, generic — retryable + | { kind: "budget-wallet" } // 429 budget_exceeded, "ExceededBudget: User=" + | { kind: "budget-global" } // 429 budget_exceeded, "Budget has been exceeded" + | { kind: "budget-unknown" } // 429 budget_exceeded, neither substring + | { kind: "too-large"; requestBytes?: number; limitBytes?: number } // 413 request_too_large + | { kind: "unauthorized" } // 401 + | { kind: "server-error"; status?: number } // 5xx + | { kind: "timeout" } // never resolves until the request's AbortSignal fires + | { kind: "malformed-json" } + +/** + * Fetch-shaped fake for the two real gateway routes. Install with `.install()` (typically in + * `beforeEach`), script the next response with `.registerNext()` / `.chatNext()` (each call + * enqueues one response; unscripted calls default to `{ kind: "ok" }`), and read + * `.registerCalls` / `.chatCalls` to assert what was actually sent. Restore with `.restore()` + * (typically in `afterEach`) to remove the `fetch` spy. + * + * One instance per test file. Do not share an instance across files — `bun test` runs each file + * in its own worker process by default, so there is no cross-file state to worry about, but + * sharing an instance across `describe` blocks within one file mixes their scripted queues. + */ +export class FakeGateway { + registerCalls: RegisterCall[] = [] + chatCalls: ChatCall[] = [] + private registerQueue: RegisterMode[] = [] + private chatQueue: ChatMode[] = [] + private spy?: ReturnType + + registerNext(mode: RegisterMode): this { + this.registerQueue.push(mode) + return this + } + chatNext(mode: ChatMode): this { + this.chatQueue.push(mode) + return this + } + + /** Clears scripted queues and call logs without touching the installed spy. */ + reset(): this { + this.registerCalls = [] + this.chatCalls = [] + this.registerQueue = [] + this.chatQueue = [] + return this + } + + install(): this { + this.spy = spyOn(globalThis, "fetch").mockImplementation( + (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.endsWith("/register")) return this.handleRegister(url, init) + if (url.includes("/v1/chat/completions")) return this.handleChat(url, init) + throw new Error(`FakeGateway: unhandled URL ${url}`) + }) as typeof fetch, + ) + return this + } + + restore(): void { + this.spy?.mockRestore() + this.spy = undefined + } + + private async handleRegister(url: string, init?: RequestInit): Promise { + const body = JSON.parse(String(init?.body)) + this.registerCalls.push({ url, installSecretHash: body.install_secret_hash, cliVersion: body.cli_version }) + const mode = this.registerQueue.shift() ?? { kind: "ok" as const } + if (mode.kind === "network") throw new Error("connection reset") + if (mode.kind === "http") return new Response("", { status: mode.status, headers: mode.headers }) + if (mode.kind === "malformed-json") return new Response("{not json", { status: 200 }) + return json({ + api_key: mode.apiKey ?? "sk-altimate-base-fake", + base_url: mode.baseUrl ?? GATEWAY_URL, + model: mode.model ?? MODEL_ID, + ...(mode.expiresAt === null + ? {} + : { expires_at: mode.expiresAt ?? new Date(Date.now() + 86_400_000).toISOString() }), + }) + } + + private async handleChat(url: string, init?: RequestInit): Promise { + const authorization = new Headers(init?.headers).get("Authorization") + this.chatCalls.push({ url, authorization, body: init?.body ? JSON.parse(String(init.body)) : undefined }) + const mode = this.chatQueue.shift() ?? { kind: "ok" as const } + switch (mode.kind) { + case "ok": + return json( + { choices: [{ message: { content: mode.content ?? "hello from altimate-base" } }] }, + mode.status ?? 200, + ) + case "throttle-tokens": + return throttleError("Limit type: tokens. Key=sk-fake. Current: 300000, Limit: 262144") + case "throttle-burst": + return throttleError("burst limit exceeded", mode.retryAfterSeconds) + case "budget-wallet": + return budgetError("ExceededBudget: User=principal-fake over budget. Spend=0.26, Budget=0.25") + case "budget-global": + return budgetError("Budget has been exceeded! Current cost: 50.01, Max budget: 50") + case "budget-unknown": + return budgetError("spend limit reached") + case "too-large": { + const size = mode.requestBytes ?? 179_608 + const limit = mode.limitBytes ?? 128_000 + const message = `Request is ${size} bytes; the free tier limit is ${limit} bytes.` + return json( + { + error: { + message, + code: "413", + provider_specific_fields: { error: { code: "request_too_large", message } }, + }, + }, + 413, + ) + } + case "unauthorized": + return new Response("", { status: 401 }) + case "server-error": + return new Response("upstream error", { status: mode.status ?? 500 }) + case "timeout": + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + }) + case "malformed-json": + return new Response("{not json", { status: 200 }) + } + } +} + +function json(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }) +} + +function throttleError(message: string, retryAfterSeconds?: number): Response { + return new Response(JSON.stringify({ error: { type: "throttling_error", message } }), { + status: 429, + headers: retryAfterSeconds !== undefined ? { "retry-after": String(retryAfterSeconds) } : {}, + }) +} + +function budgetError(message: string): Response { + return new Response(JSON.stringify({ error: { type: "budget_exceeded", message } }), { status: 429 }) +} diff --git a/packages/opencode/test/altimate/altimate-base-catalog.test.ts b/packages/opencode/test/altimate/altimate-base-catalog.test.ts new file mode 100644 index 0000000000..ef3999916a --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-catalog.test.ts @@ -0,0 +1,235 @@ +// Suite B (Deliverable 3) of docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md: the model +// catalog / provider-isolation layer for Altimate Base. This suite mostly exercises +// `src/provider/provider.ts` directly (via `Provider.list()`/`Provider.all()`/`Provider.defaultModel()`/ +// `Provider.sort()`), not the gateway's chat route — registration goes through the real +// `FreeTier.registerAfterConsent()` + `FakeGateway` `/register` route so every test starts from a +// credential that was actually minted through the production consent path, not a mocked +// `credentialsForLoad()` return value (that mocked style is what `test/provider/provider.test.ts` +// already does for its own, broader defaultModel()/config-hostility coverage — this suite is the +// complementary hermetic-harness version, scoped to Deliverable 1 Suite C). +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" +import { tmpdir } from "../fixture/fixture" + +isolateAltimateBaseHome("altimate-base-catalog") + +const { FreeTier } = await import("../../src/altimate/free/client") +const { FreeTierStore } = await import("../../src/altimate/free/store") +const { Auth } = await import("../../src/auth") +const { Provider } = await import("../../src/provider/provider") +const { ProviderID, ModelID } = await import("../../src/provider/schema") +const { Instance } = await import("../../src/project/instance") +const { ProjectID } = await import("../../src/project/schema") + +// This file plays the role of the TUI host, exactly like `altimate-base.test.ts` and +// `altimate-base-harness-smoke.test.ts` do. Minting a consent token goes through the shared +// `consented()` helper in `_fixtures/altimate-base-harness.ts`, which claims the process's ONE +// arming capability lazily and caches it — see that file for why (running multiple suite files in +// one `bun test` worker process means only the first call to `issueArmer()` may succeed). + +// Mirrors `provideProviderTestInstance` in test/provider/provider.test.ts — puts `Provider.list()`/ +// `Provider.defaultModel()` inside an isolated project Instance so their memoized `state()` is +// fresh per test directory instead of leaking across tests in this file. +function provideProviderTestInstance(input: { directory: string; fn: () => R | Promise }) { + const now = Date.now() + return Instance.restore( + { + directory: input.directory, + worktree: input.directory, + project: { + id: ProjectID.global, + worktree: input.directory, + time: { created: now, updated: now }, + sandboxes: [], + }, + }, + input.fn, + ) +} + +const gateway = new FakeGateway() + +beforeEach(async () => { + gateway.install() + gateway.reset() + await FreeTier.logout() + await FreeTierStore.remove() + resetGatewayEnv(GATEWAY_URL) +}) + +afterEach(() => { + gateway.restore() +}) + +/** Registers a real credential through the production consent path against the fake gateway. */ +async function registerCredential(): Promise { + gateway.registerNext({ kind: "ok" }) + await FreeTier.registerAfterConsent(consented()) +} + +describe("model catalog: altimate-free/altimate-base", () => { + test("a registered credential surfaces the model with the gateway-pinned limit, zero cost, and its declared capabilities", async () => { + await registerCredential() + await using tmp = await tmpdir() + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const base = providers[FreeTier.PROVIDER_ID] + expect(base).toBeDefined() + expect(base.name).toBe("Altimate") + + const model = base.models[FreeTier.MODEL_ID] + expect(model).toBeDefined() + expect(model.name).toBe("Altimate Base") + // altimate_change — family is scrubbed to the generic "altimate" brand (bec2ae37c1); the + // served model's underlying family is never disclosed publicly. + expect(model.family).toBe("altimate") + // This is the number this suite exists to guard: the offline/fallback contract must stay + // equal to what the gateway currently serves (131072/65536), not drift silently. + expect(model.limit).toEqual({ context: 131_072, output: 65_536 }) + expect(model.cost).toEqual({ input: 0, output: 0, cache: { read: 0, write: 0 } }) + expect(model.capabilities).toEqual({ + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }) + }, + }) + }) +}) + +describe("autoload gating", () => { + test("with no credential, the loader returns autoload:false: the model is absent from the connected list even though the static catalog entry always exists", async () => { + await using tmp = await tmpdir() + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers[FreeTier.PROVIDER_ID]).toBeUndefined() + + // "Registered in the catalog" and "connected" are deliberately different: the static + // database entry always exists so the model can be discovered/listed, but it only crosses + // into the connected `providers` map once credentialsForLoad() resolves. + const database = await Provider.all() + expect(database[FreeTier.PROVIDER_ID]).toBeDefined() + expect(database[FreeTier.PROVIDER_ID]!.models[FreeTier.MODEL_ID]).toBeDefined() + }, + }) + }) + + test("with a credential, the loader autoloads with the managed placeholder apiKey and authorizedFetch wired in as the fetch option", async () => { + await registerCredential() + await using tmp = await tmpdir() + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const base = providers[FreeTier.PROVIDER_ID] + expect(base).toBeDefined() + expect(base.options.baseURL).toBe(`${GATEWAY_URL}/v1`) + // The real managed key never enters Provider.Info/options — only the placeholder does, and + // authorizedFetch is what actually injects the live credential per-request. + expect(base.options.apiKey).toBe(FreeTier.MANAGED_API_KEY_PLACEHOLDER) + expect(base.options.fetch).toBe(FreeTier.authorizedFetch) + expect(JSON.stringify(base)).not.toContain("sk-altimate-base-fake") + }, + }) + }) +}) + +describe("defaultModel() and sort() for Altimate Base", () => { + test("registered Altimate Base is selected only as the last resort, once every other provider is excluded as a candidate", async () => { + await registerCredential() + // Isolate the provider set down to just altimate-free so the ordinary "opencode"/free-model + // candidates (which would otherwise always win first) cannot mask the last-resort branch. + await using tmp = await tmpdir({ config: { enabled_providers: [FreeTier.PROVIDER_ID] } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const model = await Provider.defaultModel() + expect(model).toEqual({ + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), + }) + }, + }) + }) + + test("an unregistered Altimate Base is never selected, even with every other provider excluded", async () => { + await using tmp = await tmpdir({ config: { enabled_providers: [FreeTier.PROVIDER_ID] } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const failure = await Provider.defaultModel().catch((error) => error) + expect(failure).toBeInstanceOf(Error) + expect(failure.message).toBe("no providers found") + }, + }) + }) + + test("a project provider allowlist naming Altimate Base cannot activate it as the default, even when it is the only registered candidate", async () => { + await registerCredential() + await using tmp = await tmpdir({ config: { provider: { [FreeTier.PROVIDER_ID]: {} } } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const failure = await Provider.defaultModel().catch((error) => error) + expect(failure).toBeInstanceOf(Error) + expect(failure.message).toBe("no providers found") + }, + }) + }) + + test("Provider.sort() ranks altimate-base ahead of both an unlisted model id and the retired 'big-pickle' priority slot", () => { + const sorted = Provider.sort([ + { id: "opencode/big-pickle" }, + { id: "foo/unlisted-model" }, + { id: `${FreeTier.PROVIDER_ID}/${FreeTier.MODEL_ID}` }, + ]) + expect(sorted[0]!.id).toBe(`${FreeTier.PROVIDER_ID}/${FreeTier.MODEL_ID}`) + }) +}) + +describe("two-provider isolation: altimate-free vs. altimate-backend, and the dedicated credential store", () => { + test("altimate-free ('Altimate') is a separate catalog record from the paid altimate-backend ('Altimate AI') provider", async () => { + await registerCredential() + await using tmp = await tmpdir() + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const database = await Provider.all() + const base = database[FreeTier.PROVIDER_ID] + const backend = database["altimate-backend"] + expect(base).toBeDefined() + expect(backend).toBeDefined() + expect(base!.id).not.toBe(backend!.id) + expect(base!.name).toBe("Altimate") + expect(backend!.name).toBe("Altimate AI") + // Distinct model namespaces: the free model id must not exist under the paid provider and + // vice versa — these are two providers, not one provider with two auth paths. + expect(backend!.models[FreeTier.MODEL_ID]).toBeUndefined() + expect(base!.models["altimate-default"]).toBeUndefined() + }, + }) + }) + + test("a registered Altimate Base credential lives only in its dedicated store, never in the shared provider auth store", async () => { + await registerCredential() + const stored = await FreeTierStore.read() + expect(stored?.apiKey).toBeDefined() + + // This is the deliberate design decision this suite guards: unlike altimate-backend (which can + // read its key from the shared `Auth` store — see provider.ts's "path 2" fallback for + // altimate-backend), altimate-free/Altimate Base has no such fallback. Its credential must never + // appear under its provider id in the shared store the rest of the provider system reads. + const sharedAuth = await Auth.all() + expect(sharedAuth[FreeTier.PROVIDER_ID]).toBeUndefined() + expect(JSON.stringify(sharedAuth)).not.toContain(stored!.apiKey) + }) +}) diff --git a/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts b/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts new file mode 100644 index 0000000000..e35b041a00 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts @@ -0,0 +1,172 @@ +// Suite F (renamed here to match the shipped filename) from +// docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md: inference-time failure surfacing +// that is NOT rate-limit/budget (that's `altimate-base-rate-limit-messages.test.ts`, née Suite D +// / the flagged gap in the plan's Deliverable 1 "E" table) and NOT the 401 consecutive-count state +// machine itself (that's covered exhaustively in `altimate-base.test.ts`). This file only proves +// each raw inference-time failure mode reaches `FreeTier.authorizedFetch`'s caller cleanly: +// +// - 5xx: `authorizedFetch` has no special handling for a non-401 response (client.ts:461-464) — +// it just clears the unauthorized counter and returns the `Response` as-is. Assert it passes +// through untouched and is never mis-mapped onto the rate-limit/budget message path. +// - timeout/abort: `authorizedFetch` calls `fetch()` with no try/catch around `send(active)` +// (client.ts:429-450) — a promise that only ever rejects (never resolves) once the caller's +// `AbortSignal` fires must propagate as a rejection, not hang forever. +// - connection failure: the identical no-try/catch code path handles a raw network error exactly +// like an abort — `FakeGateway`'s `ChatMode` deliberately has no `"network"` knob (only +// `RegisterMode` does; see fake-gateway.ts), so this file scripts one directly against +// `globalThis.fetch` for a single call instead of adding an unused variant to the shared +// fixture (see "Cross-file consent isolation" / ownership notes in the harness plan — the +// shared fixture is owned by whichever suite needed it first). +// - malformed JSON body: `authorizedFetch` does no JSON parsing of its own in the inference path +// (docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md, Deliverable 1 "H" table, last +// row: "Not the client's problem — passed through to the AI SDK's own JSON parsing, which is +// shared machinery, out of scope"). Assert the raw 200 response reaches the caller intact and +// that parsing the bad body — and feeding it to the Altimate-Base-specific error mappers — +// produces a sensible, catchable failure rather than a crash or corrupted state. +// - 401: the counter/rotation state machine is `altimate-base.test.ts`'s job. This file only +// confirms a chat-time 401 flows into `authorizedFetch`'s existing 401 branch instead of being +// thrown, silently discarded, or retried in a loop. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" + +isolateAltimateBaseHome("altimate-base-errors") + +const { FreeTier } = await import("../../src/altimate/free/client") +const { FreeTierStore } = await import("../../src/altimate/free/store") + +// Plays the role of the TUI host, exactly like `altimate-base.test.ts` and +// `altimate-base-harness-smoke.test.ts`. Minting a consent token goes through the shared +// `consented()` helper in `_fixtures/altimate-base-harness.ts`, which claims the process's ONE +// arming capability lazily and caches it — see that file for why (running multiple suite files in +// one `bun test` worker process means only the first call to `issueArmer()` may succeed). + +const gateway = new FakeGateway() + +function chatRequest(): [string, RequestInit] { + return [ + `${GATEWAY_URL}/v1/chat/completions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: FreeTier.MODEL_ID, messages: [{ role: "user", content: "hi" }] }), + }, + ] +} + +beforeEach(async () => { + gateway.install() + gateway.reset() + await FreeTier.logout() + await FreeTierStore.remove() + resetGatewayEnv(GATEWAY_URL) + // Every scenario below needs a live, registered credential before it can reach the inference + // path at all — seed one the same way the harness smoke test does. + gateway.registerNext({ kind: "ok" }) + await FreeTier.registerAfterConsent(consented()) +}) + +afterEach(() => { + gateway.restore() +}) + +describe("5xx pass-through during inference", () => { + test.each([500, 502, 503])("status %d surfaces as-is, without crashing or being retried", async (status) => { + gateway.chatNext({ kind: "server-error", status }) + const [url, init] = chatRequest() + const response = await FreeTier.authorizedFetch(url, init) + + expect(response.status).toBe(status) + const body = await response.text() + expect(body).toBe("upstream error") + expect(gateway.chatCalls).toHaveLength(1) + + // A 5xx must never be mis-mapped onto the rate-limit/budget message path. + // `describeRateLimit` only recognizes a JSON `{error:{type,message}}` shape; the gateway's + // plain-text 5xx body isn't that shape, so it must come back `undefined`, not a fabricated + // rate-limit or budget message. + expect(FreeTier.describeRateLimit({ body })).toBeUndefined() + }) +}) + +describe("chat-time timeout / abort", () => { + test("a request that only rejects when its AbortSignal fires propagates as a rejection, never hangs", async () => { + gateway.chatNext({ kind: "timeout" }) + const [url, init] = chatRequest() + // A real (short) timer, not a synchronous abort — `authorizedFetch` awaits `credentialsForLoad()` + // (a real file read) before it ever calls `fetch()`, so aborting synchronously right after + // kicking off the call could fire before the fake gateway's `timeout` branch has attached its + // `abort` listener. `AbortSignal.timeout` schedules the abort on a real timer instead, so it + // always fires after the listener is attached. + const promise = FreeTier.authorizedFetch(url, { ...init, signal: AbortSignal.timeout(50) }) + + await expect(promise).rejects.toMatchObject({ name: "TimeoutError" }) + // The gateway saw exactly one attempt — no swallowed retry, no hang. + expect(gateway.chatCalls).toHaveLength(1) + }) +}) + +describe("chat-time connection failure", () => { + test("a raw network error (fetch rejection) propagates cleanly instead of hanging or being swallowed", async () => { + // `FakeGateway`'s `ChatMode` has no `"network"` knob (only `RegisterMode` does) — the reason is + // that a raw network failure and an aborted/timed-out request take the identical code path in + // `authorizedFetch` (client.ts:429-450 has no try/catch around `send(active)`), so this test + // scripts the failure directly against `globalThis.fetch` for one call instead of adding an + // unused variant to the shared fixture. `gatewayFetch` captures the exact mock object + // `FakeGateway.install()` put on `globalThis.fetch` (the same object `spyOn` returned), so + // restoring it afterward leaves `gateway.restore()` in `afterEach` fully consistent. + const gatewayFetch = globalThis.fetch + const connectionReset = new Error("connection reset") + globalThis.fetch = (async () => { + throw connectionReset + }) as unknown as typeof fetch + + try { + const [url, init] = chatRequest() + const promise = FreeTier.authorizedFetch(url, init) + await expect(promise).rejects.toBe(connectionReset) + } finally { + globalThis.fetch = gatewayFetch + } + }) +}) + +describe("malformed JSON response body during inference", () => { + test("a 200 with an unparseable body reaches the caller intact instead of crashing", async () => { + gateway.chatNext({ kind: "malformed-json" }) + const [url, init] = chatRequest() + const response = await FreeTier.authorizedFetch(url, init) + + // `authorizedFetch` does no JSON parsing of its own in the inference path — it must resolve + // with the raw 200 response, not throw and not silently substitute a different status. + expect(response.status).toBe(200) + const raw = await response.text() + expect(raw).toBe("{not json") + + // Parsing the bad body is the caller's job (the AI SDK's own JSON parsing); prove that job + // produces a sensible, catchable error rather than a hang or a silently wrong value. + expect(() => JSON.parse(raw)).toThrow(SyntaxError) + + // The Altimate-Base-specific error mappers must also degrade gracefully on this same malformed + // body — both already catch a `JSON.parse` failure and return `undefined` rather than crashing. + expect(FreeTier.describeRateLimit({ body: raw })).toBeUndefined() + expect(FreeTier.describeRequestTooLarge(raw)).toBeUndefined() + }) +}) + +describe("chat-time 401", () => { + test("a 401 during inference is surfaced through authorizedFetch, not thrown or retried in a loop", async () => { + gateway.chatNext({ kind: "unauthorized" }) + const [url, init] = chatRequest() + const response = await FreeTier.authorizedFetch(url, init) + + // The consecutive-401 counter / disk-persistence state machine itself is `altimate-base.test.ts`'s + // job; this only confirms the response flows into that existing branch cleanly. + expect(response.status).toBe(401) + // Exactly one credential is registered and it hasn't crossed the disk-persistence threshold + // (client.ts:230, `REJECTED_PERSIST_THRESHOLD = 2`), so `authorizedFetch`'s retry-on-rotation + // branch (client.ts:470-474) finds `credentialsForLoad()` still returning the same `apiKey` and + // returns the original 401 without a second request. + expect(gateway.chatCalls).toHaveLength(1) + }) +}) diff --git a/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts b/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts new file mode 100644 index 0000000000..6a515f6ad2 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts @@ -0,0 +1,75 @@ +// Smoke test proving the shared Altimate Base e2e harness (`_fixtures/altimate-base-harness.ts` + +// `_fixtures/fake-gateway.ts`) works in both directions: a happy-path register -> inference +// round trip, and one scripted failure knob (a per-minute token rate-limit). This file is NOT one +// of the six planned implementer suites — see +// docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md, Deliverable 3, for that partition. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" + +isolateAltimateBaseHome("altimate-base-harness-smoke") + +const { FreeTier } = await import("../../src/altimate/free/client") +const { FreeTierStore } = await import("../../src/altimate/free/store") + +// This file plays the role of the TUI host, exactly like `altimate-base.test.ts` does. Minting a +// consent token goes through the shared `consented()` helper in `_fixtures/altimate-base-harness.ts`, +// which claims the process's ONE arming capability lazily and caches it — see that file for why +// (running multiple suite files in one `bun test` worker process means only the first call to +// `issueArmer()` may succeed). +const gateway = new FakeGateway() + +beforeEach(async () => { + gateway.install() + gateway.reset() + await FreeTier.logout() + await FreeTierStore.remove() + resetGatewayEnv(GATEWAY_URL) +}) + +afterEach(() => { + gateway.restore() +}) + +describe("Altimate Base harness smoke test", () => { + test("happy path: register then authorizedFetch round-trips a chat completion", async () => { + gateway.registerNext({ kind: "ok" }) + await FreeTier.registerAfterConsent(consented()) + expect(gateway.registerCalls).toHaveLength(1) + expect(gateway.registerCalls[0]?.installSecretHash).toMatch(/^[0-9a-f]{64}$/) + + gateway.chatNext({ kind: "ok", content: "hello from the fake gateway" }) + const response = await FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: FreeTier.MODEL_ID, messages: [{ role: "user", content: "hi" }] }), + }) + + expect(response.status).toBe(200) + const body = (await response.json()) as { choices: [{ message: { content: string } }] } + expect(body.choices[0]?.message.content).toBe("hello from the fake gateway") + + expect(gateway.chatCalls).toHaveLength(1) + expect(gateway.chatCalls[0]?.authorization).toBe("Bearer sk-altimate-base-fake") + }) + + test("failure knob: per-minute token rate-limit maps to a non-retryable message", async () => { + gateway.registerNext({ kind: "ok" }) + await FreeTier.registerAfterConsent(consented()) + + gateway.chatNext({ kind: "throttle-tokens" }) + const response = await FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: FreeTier.MODEL_ID, messages: [{ role: "user", content: "hi" }] }), + }) + + expect(response.status).toBe(429) + const described = FreeTier.describeRateLimit({ body: await response.text() }) + expect(described).toEqual({ + message: + "This request is too large for Altimate Base's per-minute token limit. Start a new session or shorten the context, then try again.", + retryable: false, + }) + }) +}) diff --git a/packages/opencode/test/altimate/altimate-base-inference-e2e.test.ts b/packages/opencode/test/altimate/altimate-base-inference-e2e.test.ts new file mode 100644 index 0000000000..3ad51c1911 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-inference-e2e.test.ts @@ -0,0 +1,185 @@ +// Altimate Base — full register -> provider-list -> authorizedFetch round trip against the shared +// hermetic `FakeGateway`, plus the security-critical placeholder-vs-real-key property and +// credential-storage isolation. This is Suite C from +// docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md, Deliverable 1 "D" / +// Deliverable 3 row "C": the gap between `altimate-base.test.ts` (unit-level, mocks +// `credentialsForLoad`) and a genuine end-to-end contract test that goes through a real +// registration, a live `Provider.list()` instance, and `authorizedFetch` itself. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import fs from "node:fs" +import path from "node:path" +import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" +import { tmpdir } from "../fixture/fixture" + +isolateAltimateBaseHome("altimate-base-inference-e2e") + +const { FreeTier } = await import("../../src/altimate/free/client") +const { FreeTierStore } = await import("../../src/altimate/free/store") +const { Provider } = await import("../../src/provider/provider") +const { Instance } = await import("../../src/project/instance") +const { ProjectID } = await import("../../src/project/schema") + +// This file plays the role of the TUI host, exactly like `altimate-base.test.ts` and +// `altimate-base-harness-smoke.test.ts` do. Minting a consent token goes through the shared +// `consented()` helper in `_fixtures/altimate-base-harness.ts`, which claims the process's ONE +// arming capability lazily and caches it — see that file for why (running multiple suite files in +// one `bun test` worker process means only the first call to `issueArmer()` may succeed). + +// A registered API key that could never be confused with `FreeTier.MANAGED_API_KEY_PLACEHOLDER` +// ("altimate-base-managed") -- distinct enough that any accidental substring match is meaningful. +const REAL_API_KEY = "sk-altimate-base-real-managed-secret-000111222" + +const gateway = new FakeGateway() + +async function registerWithGateway() { + gateway.registerNext({ kind: "ok", apiKey: REAL_API_KEY }) + return FreeTier.registerAfterConsent(consented()) +} + +// Mirrors `provideProviderTestInstance` from `test/provider/provider.test.ts` -- the established +// pattern for exercising `Provider.list()` against a real (non-mocked) `Instance` context. +function provideProviderTestInstance(input: { directory: string; fn: () => R | Promise }) { + const now = Date.now() + return Instance.restore( + { + directory: input.directory, + worktree: input.directory, + project: { + id: ProjectID.global, + worktree: input.directory, + time: { created: now, updated: now }, + sandboxes: [], + }, + }, + input.fn, + ) +} + +function chatRequestInit() { + return { + method: "POST" as const, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: FreeTier.MODEL_ID, messages: [{ role: "user", content: "hi" }] }), + } +} + +beforeEach(async () => { + gateway.install() + gateway.reset() + await FreeTier.logout() + await FreeTierStore.remove() + resetGatewayEnv(GATEWAY_URL) +}) + +afterEach(() => { + gateway.restore() +}) + +describe("Altimate Base inference e2e — happy path", () => { + test("register then authorizedFetch round-trips a chat completion", async () => { + await registerWithGateway() + + gateway.chatNext({ kind: "ok", content: "the answer is 42" }) + const response = await FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, chatRequestInit()) + + expect(response.status).toBe(200) + const body = (await response.json()) as { choices: [{ message: { content: string } }] } + expect(body.choices[0]?.message.content).toBe("the answer is 42") + expect(gateway.chatCalls).toHaveLength(1) + }) + + test("the registered model is surfaced as connected by a live Provider.list()", async () => { + await registerWithGateway() + + await using tmp = await tmpdir() + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const base = providers[FreeTier.PROVIDER_ID] + expect(base).toBeDefined() + expect(base.options.baseURL).toBe(`${GATEWAY_URL}/v1`) + // `provider.ts` wires `FreeTier.authorizedFetch` directly as the model's `fetch` option -- + // this is the exact seam a real inference call would use, so proving it's wired confirms + // the "model is usable" claim without needing to drive the full AI SDK. + expect(base.options.fetch).toBe(FreeTier.authorizedFetch) + expect(base.models[FreeTier.MODEL_ID]).toBeDefined() + }, + }) + }) +}) + +describe("Altimate Base inference e2e — managed key placeholder never goes on the wire", () => { + test("provider options carry ONLY the placeholder; authorizedFetch injects the REAL key", async () => { + await registerWithGateway() + + // 1. Prove the placeholder -- not the real key -- is what a public provider API serializes. + // `Provider.Info` is returned by public provider-listing endpoints, so anything that ends up + // here is effectively exposed. + await using tmp = await tmpdir() + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const base = providers[FreeTier.PROVIDER_ID] + expect(base.options.apiKey).toBe(FreeTier.MANAGED_API_KEY_PLACEHOLDER) + expect(JSON.stringify(base)).not.toContain(REAL_API_KEY) + }, + }) + + // 2. Prove the REAL key -- not the placeholder -- is what actually reaches the gateway when + // `authorizedFetch` (the function wired into those same provider options) is invoked. This is + // the security-critical assertion: it inspects the literal `Authorization` header the fake + // gateway received, not a mocked/assumed value. + gateway.chatNext({ kind: "ok", content: "ok" }) + await FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, chatRequestInit()) + + expect(gateway.chatCalls).toHaveLength(1) + const sentAuthorization = gateway.chatCalls[0]?.authorization + expect(sentAuthorization).toBe(`Bearer ${REAL_API_KEY}`) + expect(sentAuthorization).not.toBe(`Bearer ${FreeTier.MANAGED_API_KEY_PLACEHOLDER}`) + expect(sentAuthorization).not.toContain(FreeTier.MANAGED_API_KEY_PLACEHOLDER) + }) +}) + +describe("Altimate Base inference e2e — credential storage", () => { + test("registered credential lives ONLY in the dedicated FreeTierStore, not the shared auth store", async () => { + const sharedAuthPath = path.join(path.dirname(FreeTierStore.credentialPath()), "auth.json") + const sharedAuthBefore = fs.existsSync(sharedAuthPath) ? fs.readFileSync(sharedAuthPath) : undefined + + const result = await registerWithGateway() + + // Dedicated store: correct file, correct fields. + expect(path.basename(FreeTierStore.credentialPath())).toBe("altimate-base.json") + const stored = await FreeTierStore.read() + expect(stored?.apiKey).toBe(REAL_API_KEY) + expect(stored?.baseURL).toBe(GATEWAY_URL) + expect(stored?.apiKey).toBe(result.apiKey) + + // Restrictive perms: `store.ts` explicitly opens the temp file 0600 and chmods the final file + // 0600 before/after the atomic rename. + expect(fs.statSync(FreeTierStore.credentialPath()).mode & 0o777).toBe(0o600) + + // NOT in the shared provider auth store that lives right next to it: registration must leave + // that file byte-for-byte unchanged (absent stays absent; present stays identical), and in + // particular must never contain the real managed key. + const sharedAuthAfter = fs.existsSync(sharedAuthPath) ? fs.readFileSync(sharedAuthPath) : undefined + expect(sharedAuthAfter).toEqual(sharedAuthBefore) + if (sharedAuthAfter) expect(sharedAuthAfter.toString("utf8")).not.toContain(REAL_API_KEY) + }) +}) + +describe("Altimate Base inference e2e — credential-not-present path", () => { + test("authorizedFetch fails closed with no registered credential and never reaches the gateway", async () => { + // beforeEach already logged out and removed the store, so this test starts unregistered. + expect(await FreeTierStore.read()).toBeUndefined() + + await expect(FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, chatRequestInit())).rejects.toThrow( + "Altimate Base credentials are unavailable. Set up the model again.", + ) + + // Fails closed before any network call -- the gateway never sees the request. + expect(gateway.chatCalls).toHaveLength(0) + }) +}) diff --git a/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts b/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts new file mode 100644 index 0000000000..e7d5de5af7 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts @@ -0,0 +1,299 @@ +// Suite E (Deliverable 3 of docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md): the +// confirmed gap — `FreeTier.describeRateLimit` / `FreeTier.describeRequestTooLarge` had zero +// direct unit tests anywhere. `test/provider/error.test.ts` only exercised two paths (generic +// throttling with an empty detail, and one 413 shape) through `ProviderError.parseAPICallError`; +// none of the six `ChatMode` failure knobs below — the per-minute token limit, both budget +// surfaces plus the fallback, and the byte-limit KB math — were covered. +// +// Every scenario is driven two ways where the plan calls for it: +// 1. End to end through the fake gateway (`FakeGateway.chatNext` -> `authorizedFetch` -> the +// real response body/headers -> `describeRateLimit`/`describeRequestTooLarge`), proving the +// client's own parsing agrees with what the gateway actually sends on the wire. +// 2. Direct calls into the pure functions for branches `ChatMode` cannot express (unparseable +// bodies, a missing/unrecognized `type`, a 413 that isn't `request_too_large`, a +// `request_too_large` body whose message doesn't match the byte-count regex, the top-level +// `type` fallback instead of `error.type`) — these are still real branches in `client.ts`, +// just not shaped like anything a real gateway response would look like, so scripting them +// through `FakeGateway` would mean inventing a knob nobody asked for. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" + +isolateAltimateBaseHome("altimate-base-ratelimit") + +const { FreeTier } = await import("../../src/altimate/free/client") +const { FreeTierStore } = await import("../../src/altimate/free/store") + +// This file plays the TUI-host role exactly like `altimate-base.test.ts` and the harness smoke +// test do. Minting a consent token goes through the shared `consented()` helper in +// `_fixtures/altimate-base-harness.ts`, which claims the process's ONE arming capability lazily +// and caches it — see that file for why (running multiple suite files in one `bun test` worker +// process means only the first call to `issueArmer()` may succeed). + +const gateway = new FakeGateway() + +beforeEach(async () => { + gateway.install() + gateway.reset() + await FreeTier.logout() + await FreeTierStore.remove() + resetGatewayEnv(GATEWAY_URL) + gateway.registerNext({ kind: "ok" }) + await FreeTier.registerAfterConsent(consented()) +}) + +afterEach(() => { + gateway.restore() +}) + +/** Sends one authorized chat request against whatever `ChatMode` is currently queued. */ +async function chat(): Promise { + return FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: FreeTier.MODEL_ID, messages: [{ role: "user", content: "hi" }] }), + }) +} + +describe("describeRateLimit — via the fake gateway (every ChatMode failure knob)", () => { + test("throttle-tokens: per-minute token limit is non-retryable with the exact client.ts message", async () => { + gateway.chatNext({ kind: "throttle-tokens" }) + const response = await chat() + expect(response.status).toBe(429) + + const described = FreeTier.describeRateLimit({ + body: await response.text(), + retryAfter: response.headers.get("retry-after") ?? undefined, + }) + expect(described).toEqual({ + message: + "This request is too large for Altimate Base's per-minute token limit. Start a new session or shorten the context, then try again.", + retryable: false, + }) + }) + + test("throttle-burst: generic burst limit with a retry-after header is retryable, rounds up with Math.ceil", async () => { + gateway.chatNext({ kind: "throttle-burst", retryAfterSeconds: 45.7 }) + const response = await chat() + expect(response.status).toBe(429) + expect(response.headers.get("retry-after")).toBe("45.7") + + const described = FreeTier.describeRateLimit({ + body: await response.text(), + retryAfter: response.headers.get("retry-after") ?? undefined, + }) + expect(described).toEqual({ + message: "Too many requests to Altimate Base right now. Try again in 46s.", + retryable: true, + }) + }) + + test("throttle-burst: retry-after of 0 seconds is treated as absent (not > 0), falls back to 'shortly'", async () => { + gateway.chatNext({ kind: "throttle-burst", retryAfterSeconds: 0 }) + const response = await chat() + expect(response.headers.get("retry-after")).toBe("0") + + const described = FreeTier.describeRateLimit({ + body: await response.text(), + retryAfter: response.headers.get("retry-after") ?? undefined, + }) + expect(described).toEqual({ + message: "Too many requests to Altimate Base right now. Try again shortly.", + retryable: true, + }) + }) + + test("throttle-burst: no retry-after header at all falls back to 'shortly' (retryable)", async () => { + gateway.chatNext({ kind: "throttle-burst" }) + const response = await chat() + expect(response.headers.get("retry-after")).toBeNull() + + const described = FreeTier.describeRateLimit({ + body: await response.text(), + retryAfter: response.headers.get("retry-after") ?? undefined, + }) + expect(described).toEqual({ + message: "Too many requests to Altimate Base right now. Try again shortly.", + retryable: true, + }) + }) + + test("budget-wallet: per-principal wallet exhaustion maps to the allowance message, non-retryable", async () => { + gateway.chatNext({ kind: "budget-wallet" }) + const response = await chat() + expect(response.status).toBe(429) + + const described = FreeTier.describeRateLimit({ body: await response.text() }) + // Pinning today's message as written. Flagged ambiguity (plan Deliverable 1, Suite E / + // Summary #2): "It resets tomorrow" is arguably inaccurate for the wallet case — the + // per-principal grant (GRANT_NEW_PRINCIPAL_USD) has no budget_duration and never resets; only + // the separate global daily ceiling actually resets daily. This test asserts current + // behavior, not the plan's suggested fix, per the plan's explicit instruction not to + // silently "correct" it. + expect(described).toEqual({ + message: "You've used today's free Altimate Base allowance. It resets tomorrow—switch models to keep going.", + retryable: false, + }) + }) + + test("budget-global: shared $50/day ceiling maps to the shared-daily-limit message, non-retryable", async () => { + gateway.chatNext({ kind: "budget-global" }) + const response = await chat() + expect(response.status).toBe(429) + + const described = FreeTier.describeRateLimit({ body: await response.text() }) + expect(described).toEqual({ + message: "Altimate Base has reached its shared daily limit. It resets tomorrow—switch models to keep going.", + retryable: false, + }) + }) + + test("budget-unknown: neither known substring falls back to the generic daily-limit message, non-retryable", async () => { + gateway.chatNext({ kind: "budget-unknown" }) + const response = await chat() + expect(response.status).toBe(429) + + const described = FreeTier.describeRateLimit({ body: await response.text() }) + expect(described).toEqual({ + message: "The daily Altimate Base limit has been reached. It resets tomorrow—switch models to keep going.", + retryable: false, + }) + }) +}) + +describe("describeRateLimit — pure-function edge cases FakeGateway's ChatMode cannot express", () => { + test("unparseable JSON body returns undefined (falls through to the generic API-error path)", () => { + expect(FreeTier.describeRateLimit({ body: "{not json" })).toBeUndefined() + }) + + test("absent body returns undefined", () => { + expect(FreeTier.describeRateLimit({})).toBeUndefined() + }) + + test("well-formed JSON with an unrecognized error.type returns undefined", () => { + const body = JSON.stringify({ error: { type: "some_other_error", message: "whatever" } }) + expect(FreeTier.describeRateLimit({ body })).toBeUndefined() + }) + + test("top-level `type` is used when `error.type` is not a string (kind-resolution fallback)", () => { + // No nested `error` object at all — `kind` must fall back to the top-level `type` field + // (client.ts:497's `typeof parsed?.error?.type === "string" ? parsed.error.type : parsed?.type`). + // Detail extraction then finds no `error.message`, so `detail` is empty and this lands on the + // generic burst-limit branch, not the per-minute-token branch. + const body = JSON.stringify({ type: "throttling_error", message: "ignored, not error.message" }) + expect(FreeTier.describeRateLimit({ body })).toEqual({ + message: "Too many requests to Altimate Base right now. Try again shortly.", + retryable: true, + }) + }) +}) + +describe("describeRequestTooLarge — via the fake gateway (413 request_too_large)", () => { + test("default byte sizes: KB math rounds correctly in the '(NKB against a MKB limit)' rendering", async () => { + // FakeGateway's too-large default: requestBytes=179_608, limitBytes=128_000. + // 179608/1024 = 175.398... -> round 175. 128000/1024 = 125 exactly -> round 125. + gateway.chatNext({ kind: "too-large" }) + const response = await chat() + expect(response.status).toBe(413) + + const described = FreeTier.describeRequestTooLarge(await response.text()) + expect(described).toBe( + "This request is too large for Altimate Base (175KB against a 125KB limit). Start a new session, or switch to another model for this task.", + ) + }) + + test("custom byte sizes: KB math rounds correctly for a different pair of values", async () => { + // 1_000_000/1024 = 976.5625 -> round 977. 500_000/1024 = 488.28125 -> round 488. + gateway.chatNext({ kind: "too-large", requestBytes: 1_000_000, limitBytes: 500_000 }) + const response = await chat() + expect(response.status).toBe(413) + + const described = FreeTier.describeRequestTooLarge(await response.text()) + expect(described).toBe( + "This request is too large for Altimate Base (977KB against a 488KB limit). Start a new session, or switch to another model for this task.", + ) + }) + + test("the request_too_large code lives only in provider_specific_fields.error (FakeGateway's exact shape) — still matched", async () => { + // FakeGateway sets the outer `error.code` to the literal string "413" and only the nested + // `provider_specific_fields.error.code` to "request_too_large" — this is the real shape the + // gateway emits (matches client.ts:540's `inner?.code` check), so this scenario is really + // just re-confirming the default-sizes test above takes the inner-code path, not the + // outer-code path. Kept as its own test because it pins the exact fixture shape by name. + gateway.chatNext({ kind: "too-large", requestBytes: 50_000, limitBytes: 40_000 }) + const response = await chat() + const body = JSON.parse(await response.text()) + expect(body.error.code).toBe("413") + expect(body.error.provider_specific_fields.error.code).toBe("request_too_large") + + // 50000/1024 = 48.828125 -> round 49. 40000/1024 = 39.0625 -> round 39. + const described = FreeTier.describeRequestTooLarge(JSON.stringify(body)) + expect(described).toBe( + "This request is too large for Altimate Base (49KB against a 39KB limit). Start a new session, or switch to another model for this task.", + ) + }) +}) + +describe("describeRequestTooLarge — pure-function edge cases FakeGateway's ChatMode cannot express", () => { + test("absent body returns undefined", () => { + expect(FreeTier.describeRequestTooLarge(undefined)).toBeUndefined() + }) + + test("unparseable JSON body returns undefined", () => { + expect(FreeTier.describeRequestTooLarge("{not json")).toBeUndefined() + }) + + test("a 413 without the request_too_large code (an unrelated provider 413) returns undefined", () => { + // Falls through to error.ts's generic context_overflow handling instead of the + // Altimate-Base-specific rewrite — request_too_large and context overflow are distinct + // gateway error codes. + const body = JSON.stringify({ error: { code: "some_other_413", message: "payload too large" } }) + expect(FreeTier.describeRequestTooLarge(body)).toBeUndefined() + }) + + test("the outer error.code (not the nested provider_specific_fields shape) also matches", () => { + const body = JSON.stringify({ + error: { code: "request_too_large", message: "Request is 300000 bytes; the free tier limit is 100000 bytes." }, + }) + // 300000/1024 = 292.96875 -> round 293. 100000/1024 = 97.65625 -> round 98. + expect(FreeTier.describeRequestTooLarge(body)).toBe( + "This request is too large for Altimate Base (293KB against a 98KB limit). Start a new session, or switch to another model for this task.", + ) + }) + + test("request_too_large with a message that doesn't match the byte-count pattern omits the KB parenthetical", () => { + const body = JSON.stringify({ + error: { code: "request_too_large", message: "Payload rejected: too large for this tier." }, + }) + expect(FreeTier.describeRequestTooLarge(body)).toBe( + "This request is too large for Altimate Base. Start a new session, or switch to another model for this task.", + ) + }) + + test("request_too_large with no message at all on either the outer or inner error omits the KB parenthetical", () => { + const body = JSON.stringify({ error: { code: "request_too_large" } }) + expect(FreeTier.describeRequestTooLarge(body)).toBe( + "This request is too large for Altimate Base. Start a new session, or switch to another model for this task.", + ) + }) + + test("outer error.message missing falls back to the nested provider_specific_fields.error.message", () => { + // Exercises client.ts:542-547's ternary's else branch: `parsed.error.message` is not a + // string (absent), so `detail` comes from `inner?.message` instead. + const body = JSON.stringify({ + error: { + code: "request_too_large", + provider_specific_fields: { + error: { + code: "request_too_large", + message: "Request is 250000 bytes; the free tier limit is 128000 bytes.", + }, + }, + }, + }) + // 250000/1024 = 244.140625 -> round 244. 128000/1024 = 125 exactly. + expect(FreeTier.describeRequestTooLarge(body)).toBe( + "This request is too large for Altimate Base (244KB against a 125KB limit). Start a new session, or switch to another model for this task.", + ) + }) +}) diff --git a/packages/opencode/test/altimate/altimate-base-registration-gaps.test.ts b/packages/opencode/test/altimate/altimate-base-registration-gaps.test.ts new file mode 100644 index 0000000000..16e37b0380 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-registration-gaps.test.ts @@ -0,0 +1,176 @@ +// Registration failure-mapping gaps for Altimate Base, using the shared FakeGateway harness. +// +// `altimate-base.test.ts` already covers happy-path registration, consent enforcement, and the +// credential lifecycle (rotation, rejection, expiry) with a hand-rolled fetch mock. This file +// targets a narrower slice that suite does not exercise: how `registerOnce` in +// `src/altimate/free/client.ts` maps HTTP 4xx/5xx register failures, network failures, and +// malformed JSON register bodies onto `RegistrationError`, plus the exact request payload sent +// (hashed install secret, cli_version) and one idempotency property (a live credential is reused +// without a second gateway call). +// +// See docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md for the harness design. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { createHash } from "node:crypto" +import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" + +isolateAltimateBaseHome("altimate-base-registration") + +const { FreeTier } = await import("../../src/altimate/free/client") +const { FreeTierStore } = await import("../../src/altimate/free/store") + +// Minting a consent token goes through the shared `consented()` helper in +// `_fixtures/altimate-base-harness.ts`, which claims the process's ONE arming capability lazily +// and caches it — see that file for why (running multiple suite files in one `bun test` worker +// process means only the first call to `issueArmer()` may succeed). +const gateway = new FakeGateway() + +beforeEach(async () => { + gateway.install() + gateway.reset() + await FreeTier.logout() + await FreeTierStore.remove() + resetGatewayEnv(GATEWAY_URL) +}) + +afterEach(() => { + gateway.restore() +}) + +describe("registration failure mapping: HTTP status codes", () => { + test("429 maps to a rate-limit-specific message and carries the status", async () => { + gateway.registerNext({ kind: "http", status: 429 }) + const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + + expect(error).toBeInstanceOf(FreeTier.RegistrationError) + expect(error.kind).toBe("http") + expect(error.status).toBe(429) + expect(error.message).toBe("Too many Altimate Base registrations from this network right now. Try again later.") + }) + + test("503 maps to an unavailability-specific message and carries the status", async () => { + gateway.registerNext({ kind: "http", status: 503 }) + const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + + expect(error).toBeInstanceOf(FreeTier.RegistrationError) + expect(error.kind).toBe("http") + expect(error.status).toBe(503) + expect(error.message).toBe("Altimate Base is temporarily unavailable. Try again later.") + }) + + test("an unrecognized 5xx falls back to a generic status-carrying message", async () => { + gateway.registerNext({ kind: "http", status: 500 }) + const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + + expect(error).toBeInstanceOf(FreeTier.RegistrationError) + expect(error.kind).toBe("http") + expect(error.status).toBe(500) + expect(error.message).toBe("Altimate Base registration failed (HTTP 500).") + }) + + test("a 4xx that is not specially handled (400) still maps generically, not as a network/response failure", async () => { + gateway.registerNext({ kind: "http", status: 400 }) + const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + + expect(error).toBeInstanceOf(FreeTier.RegistrationError) + expect(error.kind).toBe("http") + expect(error.status).toBe(400) + expect(error.message).toBe("Altimate Base registration failed (HTTP 400).") + }) + + test("an HTTP register failure never persists credentials or flips the registered state", async () => { + gateway.registerNext({ kind: "http", status: 500 }) + await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + + expect(await FreeTier.isRegistered()).toBe(false) + expect(await FreeTier.credentials()).toBeUndefined() + // The install secret is minted and persisted BEFORE the network call (so a lost response can't + // mint a second budget principal on retry), so the store is expected to hold it even though + // registration failed — but it must hold nothing else. + const stored = await FreeTierStore.read() + expect(stored?.installSecret).toMatch(/^[0-9a-f]{64}$/) + expect(stored?.apiKey).toBeUndefined() + expect(stored?.baseURL).toBeUndefined() + }) +}) + +describe("registration failure mapping: network failure", () => { + test('a thrown fetch (connection failure) maps to kind "network" with a connectivity message', async () => { + gateway.registerNext({ kind: "network" }) + const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + + expect(error).toBeInstanceOf(FreeTier.RegistrationError) + expect(error.kind).toBe("network") + expect(error.status).toBeUndefined() + expect(error.message).toBe("Could not reach the Altimate Base gateway. Check your connection.") + expect(await FreeTier.isRegistered()).toBe(false) + }) +}) + +describe("registration failure mapping: malformed register response", () => { + test('a 200 with invalid JSON body maps to kind "response" instead of crashing', async () => { + gateway.registerNext({ kind: "malformed-json" }) + const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + + expect(error).toBeInstanceOf(FreeTier.RegistrationError) + expect(error.kind).toBe("response") + expect(error.status).toBeUndefined() + expect(error.message).toBe("The Altimate Base gateway returned an unexpected response.") + expect(await FreeTier.isRegistered()).toBe(false) + expect(await FreeTier.credentials()).toBeUndefined() + }) +}) + +describe("registration request payload", () => { + test("sends only the SHA-256 hash of the minted install secret, never the secret itself", async () => { + gateway.registerNext({ kind: "ok" }) + const result = await FreeTier.registerAfterConsent(consented()) + + expect(gateway.registerCalls).toHaveLength(1) + const call = gateway.registerCalls[0]! + expect(call.installSecretHash).toMatch(/^[0-9a-f]{64}$/) + expect(call.installSecretHash).toBe(createHash("sha256").update(result.installSecret).digest("hex")) + expect(call.installSecretHash).not.toBe(result.installSecret) + }) + + test("sends a sanitized cli_version derived from the running Installation.VERSION", async () => { + const { Installation } = await import("../../src/installation") + gateway.registerNext({ kind: "ok" }) + await FreeTier.registerAfterConsent(consented()) + + expect(gateway.registerCalls).toHaveLength(1) + const sentVersion = gateway.registerCalls[0]!.cliVersion + expect(sentVersion).toBe(FreeTier.sanitizeCliVersion(Installation.VERSION)) + // sanitizeCliVersion's contract: only these characters survive, capped at 32 chars, never empty. + expect(sentVersion).toMatch(/^[A-Za-z0-9._+-]{1,32}$/) + }) +}) + +describe("registration retry / idempotency", () => { + test("a failed HTTP registration reuses the same minted install secret on the next consented attempt", async () => { + gateway.registerNext({ kind: "http", status: 500 }) + await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + const firstHash = gateway.registerCalls[0]?.installSecretHash + expect(firstHash).toMatch(/^[0-9a-f]{64}$/) + + gateway.registerNext({ kind: "ok" }) + const result = await FreeTier.registerAfterConsent(consented()) + + expect(gateway.registerCalls).toHaveLength(2) + expect(gateway.registerCalls[1]?.installSecretHash).toBe(firstHash) + expect(createHash("sha256").update(result.installSecret).digest("hex")).toBe(firstHash) + }) + + test("re-registering with a live credential is a no-op: the gateway is not called again", async () => { + gateway.registerNext({ kind: "ok" }) + const first = await FreeTier.registerAfterConsent(consented()) + expect(gateway.registerCalls).toHaveLength(1) + + // A second, independently-armed consent token still must not trigger another /register call, + // because registerAfterConsent finds the existing credential is live, unexpired, and not + // rejected before ever reaching registerOnce. + const second = await FreeTier.registerAfterConsent(consented()) + expect(gateway.registerCalls).toHaveLength(1) + expect(second).toEqual(first) + }) +}) diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts new file mode 100644 index 0000000000..ce7bf4c5f5 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -0,0 +1,702 @@ +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { createHash, randomBytes } from "node:crypto" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { consented } from "./_fixtures/altimate-base-harness" + +const isolatedEnvironment = [ + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "OPENCODE_TEST_HOME", +] as const +const originalEnvironment = Object.fromEntries(isolatedEnvironment.map((key) => [key, process.env[key]])) +const temporaryHome = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-base-")) +process.env.XDG_DATA_HOME = path.join(temporaryHome, "data") +process.env.XDG_CONFIG_HOME = path.join(temporaryHome, "config") +process.env.XDG_CACHE_HOME = path.join(temporaryHome, "cache") +process.env.XDG_STATE_HOME = path.join(temporaryHome, "state") +process.env.OPENCODE_TEST_HOME = temporaryHome + +const { FreeTier } = await import("../../src/altimate/free/client") +const { FreeTierStore } = await import("../../src/altimate/free/store") +const { FreeTierConsent } = await import("../../src/altimate/free/consent") +const { FreeTierCapability } = await import("../../src/altimate/free/capability") +const { Flock } = await import("@opencode-ai/core/util/flock") + +const GATEWAY_URL = "https://gateway.test" +const REGISTERED = { + api_key: "sk-altimate-base-1", + base_url: GATEWAY_URL, + model: FreeTier.MODEL_ID, + expires_at: new Date(Date.now() + 86_400_000).toISOString(), +} + +let fetchSpy: ReturnType | undefined + +function mockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise) { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation(handler as typeof fetch) + return fetchSpy +} + +function json(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +beforeEach(async () => { + fetchSpy?.mockRestore() + fetchSpy = undefined + // Exercise the production disconnect path, then remove its retained fair-use identity so each + // test starts as a genuinely fresh installation. + await FreeTier.logout() + await FreeTierStore.remove() + delete process.env.ALTIMATE_BASE_GATEWAY_URL + delete process.env.ALTIMATE_FREE_GATEWAY_URL + process.env.ALTIMATE_BASE_GATEWAY_URL = GATEWAY_URL +}) + +afterEach(() => { + fetchSpy?.mockRestore() + fetchSpy = undefined +}) + +afterAll(() => { + for (const key of isolatedEnvironment) { + const value = originalEnvironment[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + fs.rmSync(temporaryHome, { recursive: true, force: true }) +}) + +// This test file plays the role of the TUI host: minting a consent token goes through the shared +// `consented()` helper in `_fixtures/altimate-base-harness.ts`, which claims `issueArmer()` — the +// process's ONE arming capability, exactly as `cli/tui/worker.ts` does at boot — lazily and caches +// it, so every suite file sharing this process gets the SAME armer instead of each one claiming it +// independently (which would throw on the second file). Every `FreeTier.registerAfterConsent` call +// in this file therefore goes through the SAME path production does; nothing here constructs a +// private, independent store that `registerAfterConsent` would actually trust (see "unforgeable +// consent" below for a direct test of that property). + +describe("gateway configuration", () => { + test("requires source-mode configuration and prefers the new override", () => { + delete process.env.ALTIMATE_BASE_GATEWAY_URL + expect(() => FreeTier.gatewayUrl()).toThrow(FreeTier.ConfigurationError) + process.env.ALTIMATE_FREE_GATEWAY_URL = "https://legacy-gateway.example/" + expect(FreeTier.gatewayUrl()).toBe("https://legacy-gateway.example") + process.env.ALTIMATE_BASE_GATEWAY_URL = "https://future-gateway.example/root/" + expect(FreeTier.gatewayUrl()).toBe("https://future-gateway.example/root") + }) + + test("rejects unsafe configured URLs", () => { + for (const value of [ + "http://gateway.example.com", + "http://localhost:4000", + "https://user:pass@gateway.example.com", + "https://gateway.example.com/?target=elsewhere", + "https://gateway.example.com/#fragment", + "https://gateway.example.com?", + "https://gateway.example.com#", + "not-a-url", + ]) { + process.env.ALTIMATE_BASE_GATEWAY_URL = value + expect(() => FreeTier.gatewayUrl()).toThrow(FreeTier.ConfigurationError) + } + }) +}) + +describe("registration", () => { + test("stores only a hash remotely and keeps credentials in the dedicated file", async () => { + let requestBody: Record | undefined + const sharedAuthPath = path.join(path.dirname(FreeTierStore.credentialPath()), "auth.json") + const sharedAuthBefore = fs.existsSync(sharedAuthPath) ? fs.readFileSync(sharedAuthPath) : undefined + mockFetch(async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return json(REGISTERED) + }) + + const result = await FreeTier.registerAfterConsent(consented()) + const sentHash = String(requestBody?.install_secret_hash) + expect(sentHash).toMatch(/^[0-9a-f]{64}$/) + expect(sentHash).toBe(createHash("sha256").update(result.installSecret).digest("hex")) + expect(sentHash).not.toBe(result.installSecret) + expect(await FreeTier.credentials()).toEqual(result) + expect(path.basename(FreeTierStore.credentialPath())).toBe("altimate-base.json") + expect(fs.statSync(FreeTierStore.credentialPath()).mode & 0o777).toBe(0o600) + // The full suite may already have auth.json from unrelated auth tests. Pin the actual isolation + // property by proving registration leaves that shared store byte-for-byte unchanged. + const sharedAuthAfter = fs.existsSync(sharedAuthPath) ? fs.readFileSync(sharedAuthPath) : undefined + expect(sharedAuthAfter).toEqual(sharedAuthBefore) + }) + + test("registration is impossible without an armed consent capability", async () => { + let gatewayCalls = 0 + mockFetch(() => { + gatewayCalls++ + return json(REGISTERED) + }) + const forged = randomBytes(32).toString("hex") + + // A token that was never armed through the legitimate path cannot register, and nothing + // reaches the network or the credential file. This is the property the whole consent design + // rests on. + await expect(FreeTier.registerAfterConsent(forged)).rejects.toBeInstanceOf(FreeTier.RegistrationError) + expect(gatewayCalls).toBe(0) + expect(await FreeTierStore.read()).toBeUndefined() + + const token = consented() + const result = await FreeTier.registerAfterConsent(token) + expect(result.apiKey).toBe(REGISTERED.api_key) + expect(gatewayCalls).toBe(1) + + // One-shot: the same token cannot register a second time. + await expect(FreeTier.registerAfterConsent(token)).rejects.toBeInstanceOf(FreeTier.RegistrationError) + expect(gatewayCalls).toBe(1) + }) + + test("unforgeable consent: no in-process caller can mint an independent authority", async () => { + // `consented()`'s module-scope setup above already claimed the process's ONE armer, exactly + // as the TUI worker does at boot; `client.ts` claims the matching ONE redeemer at import + // time. This test plays the attacker: it tries to obtain either capability a second time, + // and separately proves that a self-constructed, self-armed store is inert against the real + // registration function. Both are the properties `registerAfterConsent`'s unforgeability + // rests on. + expect(() => FreeTierCapability.issueArmer()).toThrow() + expect(() => FreeTierCapability.issueRedeemer()).toThrow() + + // Constructing your own store and arming it — exactly the exploit a caller-supplied capability + // used to allow — produces a token that only ever validates against ITSELF. The store happily + // reports it as consumed, but `registerAfterConsent` no longer accepts a capability argument at + // all, only a bare token checked against the private, one-shot-issued authority above, so this + // "successfully consumed" forged token still cannot register. + let gatewayCalls = 0 + mockFetch(() => { + gatewayCalls++ + return json(REGISTERED) + }) + const forgedStore = new FreeTierCapability.ConsentCapabilityStore() + const forgedToken = randomBytes(32).toString("hex") + forgedStore.arm(forgedToken) + expect(forgedStore.consume(forgedToken)).toBe(true) + await expect(FreeTier.registerAfterConsent(forgedToken)).rejects.toBeInstanceOf(FreeTier.RegistrationError) + expect(gatewayCalls).toBe(0) + }) + + test("rejects a registration response that redirects credentials to another origin", async () => { + mockFetch(() => json({ ...REGISTERED, base_url: "https://attacker.example.com" })) + await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("rejects a registration response that changes the configured gateway path", async () => { + mockFetch(() => json({ ...REGISTERED, base_url: `${GATEWAY_URL}/unexpected-proxy` })) + await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("rejects a response for a different model", async () => { + mockFetch(() => json({ ...REGISTERED, model: "another-model" })) + await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("rejects an already-expired credential response", async () => { + mockFetch(() => json({ ...REGISTERED, expires_at: new Date(Date.now() - 1_000).toISOString() })) + await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("reuses a live credential without another registration request", async () => { + await FreeTierStore.write({ + version: 1, + installSecret: "existing-install-secret", + apiKey: REGISTERED.api_key, + baseURL: REGISTERED.base_url, + expiresAt: REGISTERED.expires_at, + }) + let calls = 0 + mockFetch(() => { + calls++ + return json(REGISTERED) + }) + + const result = await FreeTier.registerAfterConsent(consented()) + expect(result.apiKey).toBe(REGISTERED.api_key) + expect(calls).toBe(0) + }) + + test("repairs a malformed dedicated credential record only after explicit registration", async () => { + fs.mkdirSync(path.dirname(FreeTierStore.credentialPath()), { recursive: true }) + fs.writeFileSync(FreeTierStore.credentialPath(), "{truncated", { mode: 0o600 }) + mockFetch(() => json(REGISTERED)) + + await expect(FreeTier.credentialsForLoad()).rejects.toBeInstanceOf(FreeTierStore.InvalidCredentialStoreError) + const result = await FreeTier.registerAfterConsent(consented()) + expect(result.apiKey).toBe(REGISTERED.api_key) + expect(await FreeTier.credentials()).toEqual(result) + }) + + test("reuses the install secret after a lost response", async () => { + let firstHash = "" + mockFetch((_input, init) => { + firstHash = String(JSON.parse(String(init?.body)).install_secret_hash) + throw new Error("connection reset") + }) + await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + fetchSpy?.mockRestore() + + let secondHash = "" + mockFetch((_input, init) => { + secondHash = String(JSON.parse(String(init?.body)).install_secret_hash) + return json(REGISTERED) + }) + await FreeTier.registerAfterConsent(consented()) + expect(secondHash).toBe(firstHash) + }) + + test("distinguishes network failures from invalid gateway responses", async () => { + mockFetch(() => { + throw new Error("connection reset") + }) + const network = await FreeTier.registerAfterConsent(consented()).catch((error) => error) + expect(network).toBeInstanceOf(FreeTier.RegistrationError) + expect(network.kind).toBe("network") + fetchSpy?.mockRestore() + + mockFetch(() => json({ ...REGISTERED, api_key: "" })) + const response = await FreeTier.registerAfterConsent(consented()).catch((error) => error) + expect(response).toBeInstanceOf(FreeTier.RegistrationError) + expect(response.kind).toBe("response") + expect(response.status).toBeUndefined() + }) + + test("cancels an in-flight gateway registration when its caller is dismissed", async () => { + const controller = new AbortController() + let started!: () => void + const requestStarted = new Promise((resolve) => { + started = resolve + }) + let requestAborted = false + mockFetch((_input, init) => { + started() + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + requestAborted = true + reject(init.signal?.reason) + }, + { once: true }, + ) + }) + }) + + const pending = FreeTier.registerAfterConsent(consented(), { signal: controller.signal }) + await requestStarted + controller.abort() + + await expect(pending).rejects.toBeInstanceOf(FreeTier.RegistrationError) + expect(requestAborted).toBe(true) + }) + + test("does not reconnect when logout wins the lock before a pending registration", async () => { + const registered = { + version: 1 as const, + installSecret: "stable-install-secret", + logoutNonce: "before-logout", + apiKey: "rejected-key", + baseURL: GATEWAY_URL, + rejected: true, + } + await FreeTierStore.write(registered) + + let gatewayCalls = 0 + mockFetch(() => { + gatewayCalls++ + return json(REGISTERED) + }) + + let releaseLock!: () => void + let lockAcquired!: () => void + const acquired = new Promise((resolve) => { + lockAcquired = resolve + }) + const release = new Promise((resolve) => { + releaseLock = resolve + }) + const holder = Flock.withLock("altimate-base-registration", async () => { + lockAcquired() + await release + }) + await acquired + + const originalRead = FreeTierStore.read + let baselineRead!: () => void + const baselineObserved = new Promise((resolve) => { + baselineRead = resolve + }) + const readSpy = spyOn(FreeTierStore, "read").mockImplementation(async () => { + const value = await originalRead() + baselineRead() + return value + }) + const pending = FreeTier.registerAfterConsent(consented()) + await baselineObserved + readSpy.mockRestore() + + // Model another process winning the same file lock with logout after registration captured the + // old generation. The pending operation must recheck before making a gateway request. + await FreeTierStore.write({ + version: 1, + installSecret: registered.installSecret, + logoutNonce: "after-logout", + }) + releaseLock() + await holder + + const error = await pending.catch((cause) => cause) + expect(error).toBeInstanceOf(FreeTier.RegistrationError) + expect(error.kind).toBe("cancelled") + expect(gatewayCalls).toBe(0) + expect(await FreeTier.credentials()).toBeUndefined() + expect(await FreeTierStore.read()).toMatchObject({ + installSecret: registered.installSecret, + logoutNonce: "after-logout", + }) + }) +}) + +describe("inference boundary", () => { + async function seed(overrides: Partial[0]> = {}) { + await FreeTierStore.write({ + version: 1, + installSecret: "install-secret", + apiKey: REGISTERED.api_key, + baseURL: REGISTERED.base_url, + ...overrides, + }) + } + + test("fails closed without credentials", async () => { + let calls = 0 + mockFetch(() => { + calls++ + return new Response("", { status: 200 }) + }) + await expect( + FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + headers: { Authorization: "Bearer stale" }, + body: '{"prompt":"secret"}', + }), + ).rejects.toThrow("credentials are unavailable") + expect(calls).toBe(0) + }) + + test("does not load credentials issued for a previously configured gateway", async () => { + await seed({ baseURL: `${GATEWAY_URL}/old-path` }) + + expect(await FreeTier.credentialsForLoad()).toBeUndefined() + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("fails closed on expired credentials without registering during provider discovery", async () => { + await seed({ expiresAt: new Date(Date.now() - 1_000).toISOString() }) + let registrations = 0 + mockFetch((input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.endsWith("/register")) registrations++ + return json({ ...REGISTERED, api_key: "sk-altimate-base-refreshed" }) + }) + + expect(await FreeTier.credentialsForLoad()).toBeUndefined() + expect(registrations).toBe(0) + }) + + test("blocks a mismatched origin before sending the stale header or prompt", async () => { + await seed() + let calls = 0 + mockFetch(() => { + calls++ + return new Response("", { status: 200 }) + }) + await expect( + FreeTier.authorizedFetch("https://attacker.example.com/v1/chat/completions", { + method: "POST", + headers: { Authorization: "Bearer stale" }, + body: '{"prompt":"secret"}', + }), + ).rejects.toThrow("unregistered gateway origin") + expect(calls).toBe(0) + }) + + test("overwrites stale authorization and disables redirects", async () => { + await seed() + let authorization: string | null = null + let redirect: RequestRedirect | undefined + mockFetch((_input, init) => { + authorization = new Headers(init?.headers).get("Authorization") + redirect = init?.redirect + return new Response("{}", { status: 200 }) + }) + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + headers: { Authorization: "Bearer stale" }, + body: "{}", + }) + expect(response.status).toBe(200) + expect(authorization).toBe(`Bearer ${REGISTERED.api_key}`) + expect(redirect).toBe("manual") + }) + + test("a successful request reads the credential store exactly once", async () => { + await seed() + const reads = spyOn(FreeTierStore, "read") + mockFetch(() => new Response("{}", { status: 200 })) + try { + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(200) + expect(reads).toHaveBeenCalledTimes(1) + } finally { + reads.mockRestore() + } + }) + + test("never sends a rotated credential issued for another origin", async () => { + await seed() + const authorizations: (string | null)[] = [] + mockFetch(async (_input, init) => { + authorizations.push(new Headers(init?.headers).get("Authorization")) + await seed({ apiKey: "sk-evil", baseURL: "https://attacker.example.com" }) + return new Response("", { status: 401 }) + }) + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(401) + expect(authorizations).toEqual([`Bearer ${REGISTERED.api_key}`]) + }) + + test("retries once with a credential already rotated by another consented process", async () => { + await seed() + const authorizations: (string | null)[] = [] + mockFetch(async (_input, init) => { + const authorization = new Headers(init?.headers).get("Authorization") + authorizations.push(authorization) + if (authorization === `Bearer ${REGISTERED.api_key}`) { + await seed({ apiKey: "sk-altimate-base-rotated" }) + return new Response("", { status: 401 }) + } + return new Response("{}", { status: 200 }) + }) + + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(200) + expect(authorizations).toEqual([`Bearer ${REGISTERED.api_key}`, "Bearer sk-altimate-base-rotated"]) + }) + + test("a single 401 does not disown the credential on disk", async () => { + // Distinct key per test: the consecutive-401 counter is keyed by credential fingerprint and + // is module state, so reusing REGISTERED.api_key would inherit counts from earlier tests. + await seed({ apiKey: "sk-401-single" }) + mockFetch(() => new Response("", { status: 401 })) + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(401) + // Blocked for this process, but a relaunch must retry: one 401 can be a gateway deploy or + // key-propagation skew, and persisting it would force every user back through the disclosure. + expect((await FreeTierStore.read())?.rejected).toBeUndefined() + }) + + test("consecutive 401s do disown the credential on disk", async () => { + await seed({ apiKey: "sk-401-consecutive" }) + mockFetch(() => new Response("", { status: 401 })) + for (let attempt = 0; attempt < 2; attempt++) { + await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { method: "POST", body: "{}" }) + } + expect((await FreeTierStore.read())?.rejected).toBe(true) + }) + + test("a success between 401s resets the consecutive count", async () => { + await seed({ apiKey: "sk-401-reset" }) + let status = 401 + mockFetch(() => new Response("{}", { status })) + const call = () => + FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { method: "POST", body: "{}" }) + + await call() + status = 200 + await call() + status = 401 + await call() + expect((await FreeTierStore.read())?.rejected).toBeUndefined() + }) + + test("a non-401, non-2xx response between 401s also resets the consecutive count", async () => { + // A 429/503 (or any other non-401 status) is not an auth rejection either — the gateway would + // return 401 specifically for a rejected key. Gating the reset on `response.ok` alone let a + // 401 that happened to straddle an unrelated rate-limit or outage response still reach the + // persistence threshold and disown a credential the gateway never actually rejected. + await seed({ apiKey: "sk-401-mixed-reset" }) + let status = 401 + mockFetch(() => new Response("", { status })) + const call = () => + FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { method: "POST", body: "{}" }) + + await call() + status = 429 + await call() + status = 401 + await call() + expect((await FreeTierStore.read())?.rejected).toBeUndefined() + }) + + test("a 401 never triggers background registration", async () => { + await seed() + const urls: string[] = [] + mockFetch((input) => { + urls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url) + return new Response("", { status: 401 }) + }) + + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(401) + expect(urls).toEqual([`${REGISTERED.base_url}/v1/chat/completions`]) + expect(await FreeTierStore.read()).toMatchObject({ rejected: true }) + expect(await FreeTier.credentialsForLoad()).toBeUndefined() + }) + + test("a non-success response does not clear a concurrently persisted rejection", async () => { + await seed() + mockFetch(async () => { + await seed({ rejected: true }) + return new Response("unavailable", { status: 500 }) + }) + + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + + expect(response.status).toBe(500) + expect(await FreeTierStore.read()).toMatchObject({ rejected: true }) + }) + + test("a late success does not clear a rejection recorded by a concurrent 401", async () => { + await seed() + mockFetch(async () => { + await seed({ rejected: true }) + return new Response("{}", { status: 200 }) + }) + + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + + expect(response.status).toBe(200) + expect(await FreeTierStore.read()).toMatchObject({ rejected: true }) + expect(await FreeTier.credentialsForLoad()).toBeUndefined() + }) + + test("explicit consent rotates an unexpired credential rejected by inference", async () => { + await seed({ expiresAt: REGISTERED.expires_at }) + const urls: string[] = [] + mockFetch((input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + urls.push(url) + if (url.endsWith("/register")) return json({ ...REGISTERED, api_key: "sk-altimate-base-rotated" }) + return new Response("", { status: 401 }) + }) + + const rejected = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(rejected.status).toBe(401) + + const rotated = await FreeTier.registerAfterConsent(consented()) + expect(rotated.apiKey).toBe("sk-altimate-base-rotated") + expect(urls).toEqual([`${REGISTERED.base_url}/v1/chat/completions`, `${REGISTERED.base_url}/register`]) + }) +}) + +describe("consent boundary", () => { + test("overlapping one-shot capabilities survive mismatches and remain independent", async () => { + const first = "a".repeat(64) + const second = "b".repeat(64) + let registrations = 0 + // Exercises the gate's arm/register plumbing in isolation, via its own independent store — + // deliberately NOT the production authority `consented()` above uses, since this test is + // about the gate's wiring, not about the real unforgeability property (covered separately). + const store = new FreeTierCapability.ConsentCapabilityStore() + const gate = FreeTierConsent.createRegistrationConsentGate({ + arm: (token) => store.arm(token), + register: async (token) => { + if (!store.consume(token)) throw new FreeTier.RegistrationError("consent expired", "cancelled") + registrations++ + }, + }) + + gate.setToken({ token: first }) + gate.setToken({ token: second }) + expect((await gate.register({ token: "c".repeat(64) })).ok).toBe(false) + expect((await gate.register({ token: first })).ok).toBe(true) + expect((await gate.register({ token: first })).ok).toBe(false) + expect((await gate.register({ token: second })).ok).toBe(true) + expect(registrations).toBe(2) + }) + + test("pending capabilities are bounded and expire", () => { + let now = 1_000 + const capabilities = new FreeTierCapability.ConsentCapabilityStore({ maxPending: 2, ttlMs: 50, now: () => now }) + const first = "a".repeat(64) + const second = "b".repeat(64) + const third = "c".repeat(64) + capabilities.arm(first) + capabilities.arm(second) + capabilities.arm(third) + expect(capabilities.consume(first)).toBe(false) + expect(capabilities.consume(second)).toBe(true) + now += 51 + expect(capabilities.consume(third)).toBe(false) + }) + + test("only transport failures are surfaced as network failures", async () => { + const token = "d".repeat(64) + const network = FreeTierConsent.createRegistrationConsentGate({ + arm: () => {}, + register: async () => { + throw new FreeTier.RegistrationError("offline", "network") + }, + }) + network.setToken({ token }) + expect(await network.register({ token })).toMatchObject({ ok: false, result: "network" }) + + const invalidResponse = FreeTierConsent.createRegistrationConsentGate({ + arm: () => {}, + register: async () => { + throw new FreeTier.RegistrationError("invalid", "response") + }, + }) + invalidResponse.setToken({ token }) + expect(await invalidResponse.register({ token })).toMatchObject({ ok: false, result: "error" }) + }) +}) diff --git a/packages/opencode/test/altimate/connections.test.ts b/packages/opencode/test/altimate/connections.test.ts index 8224274560..38467134ce 100644 --- a/packages/opencode/test/altimate/connections.test.ts +++ b/packages/opencode/test/altimate/connections.test.ts @@ -750,6 +750,7 @@ ch_project: port: 8443 user: default password: secret + secure: true database: analytics schema: default `, @@ -763,6 +764,7 @@ ch_project: expect(connections[0].config.host).toBe("clickhouse.example.com") expect(connections[0].config.port).toBe(8443) expect(connections[0].config.user).toBe("default") + expect(connections[0].config.secure).toBe(true) expect(connections[0].config.database).toBe("analytics") } finally { fs.rmSync(tmpDir, { recursive: true }) diff --git a/packages/opencode/test/altimate/driver-normalize.test.ts b/packages/opencode/test/altimate/driver-normalize.test.ts index dc3eb34e25..f39c839190 100644 --- a/packages/opencode/test/altimate/driver-normalize.test.ts +++ b/packages/opencode/test/altimate/driver-normalize.test.ts @@ -866,6 +866,15 @@ describe("normalizeConfig — ClickHouse", () => { expect(normalizeConfig(config)).toEqual(config) }) + test("preserves dbt-clickhouse secure intent", () => { + const config = { + type: "clickhouse", + host: "secure.example", + secure: true, + } + expect(normalizeConfig(config)).toEqual(config) + }) + test("connectionString → connection_string", () => { const result = normalizeConfig({ type: "clickhouse", diff --git a/packages/opencode/test/altimate/telemetry/onboarding.test.ts b/packages/opencode/test/altimate/telemetry/onboarding.test.ts index 563af6c467..fd252a6c02 100644 --- a/packages/opencode/test/altimate/telemetry/onboarding.test.ts +++ b/packages/opencode/test/altimate/telemetry/onboarding.test.ts @@ -87,7 +87,7 @@ describe("onboarding abandonment", () => { await Onboarding.emit({ type: "onboarding_started" }) await Onboarding.emit({ type: "gateway_device_code_issued" }) - await Onboarding.emit({ type: "model_picker_shown", trigger: "big_pickle_back" }) + await Onboarding.emit({ type: "model_picker_shown", trigger: "altimate_base_back" }) await Onboarding.emitAbandonedIfIncomplete() await settle() diff --git a/packages/opencode/test/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts new file mode 100644 index 0000000000..ef80e4e3ea --- /dev/null +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -0,0 +1,100 @@ +import { describe, expect } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Effect } from "effect" +import { cliIt } from "../lib/cli-process" + +describe("providers logout", () => { + cliIt.live( + "removes Altimate Base independently from ordinary provider credentials", + ({ home, opencode }) => + Effect.gen(function* () { + const dataDir = path.join(home, ".local", "share", "altimate-code") + const basePath = path.join(dataDir, "altimate-base.json") + const authPath = path.join(dataDir, "auth.json") + const disconnectedBase = { + version: 1, + installSecret: "test-install-secret", + } + const registeredBase = { + ...disconnectedBase, + apiKey: "sk-altimate-base-test", + baseURL: "https://gateway.test", + expiresAt: "2099-01-01T00:00:00.000Z", + rejected: true, + } + const anthropic = { type: "api", key: "anthropic-test-key" } + const writeBase = (record: typeof disconnectedBase | typeof registeredBase) => + fs.writeFile(basePath, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 }) + + yield* Effect.promise(() => fs.mkdir(dataDir, { recursive: true })) + yield* Effect.promise(() => writeBase(registeredBase)) + yield* Effect.promise(() => + fs.writeFile( + authPath, + JSON.stringify({ + anthropic, + // Old builds could leave this entry in the shared auth store. Base logout owns only + // this reserved provider ID and must preserve every unrelated credential. + "altimate-free": { type: "api", key: "legacy-base-key" }, + }), + { mode: 0o600 }, + ), + ) + + const baseLogout = yield* opencode.spawn(["providers", "logout", "altimate-base"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(baseLogout, 0, "providers logout altimate-base") + expect(baseLogout.stdout).toContain("Logout successful") + // Logout strips every usable credential field while retaining the local fair-use identity. + // A later consented setup therefore reuses the same gateway budget principal. + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(basePath, "utf8")))).toEqual({ + ...disconnectedBase, + logoutNonce: expect.stringMatching(/^[0-9a-f]{32}$/), + }) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) + + const disconnectedBeforeRepeatedLogout = JSON.parse( + yield* Effect.promise(() => fs.readFile(basePath, "utf8")), + ) + const repeatedBaseLogout = yield* opencode.spawn(["providers", "logout", "altimate-base"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(repeatedBaseLogout, 0, "providers logout disconnected altimate-base") + expect(repeatedBaseLogout.stdout).toContain("Logout successful") + const disconnectedAfterRepeatedLogout = JSON.parse( + yield* Effect.promise(() => fs.readFile(basePath, "utf8")), + ) + expect(disconnectedAfterRepeatedLogout).toEqual({ + ...disconnectedBase, + logoutNonce: expect.stringMatching(/^[0-9a-f]{32}$/), + }) + expect(disconnectedAfterRepeatedLogout.logoutNonce).not.toBe(disconnectedBeforeRepeatedLogout.logoutNonce) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) + + yield* Effect.promise(() => writeBase(registeredBase)) + const baseBeforeGenericLogout = yield* Effect.promise(() => fs.readFile(basePath, "utf8")) + const genericLogout = yield* opencode.spawn(["providers", "logout", "anthropic"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(genericLogout, 0, "providers logout anthropic") + expect(genericLogout.stdout).toContain("Logout successful") + expect(yield* Effect.promise(() => fs.readFile(basePath, "utf8"))).toBe(baseBeforeGenericLogout) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({}) + + yield* Effect.promise(() => fs.writeFile(basePath, "{truncated", { mode: 0o600 })) + const malformedBaseLogout = yield* opencode.spawn(["providers", "logout", "altimate-base"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(malformedBaseLogout, 0, "providers logout malformed altimate-base") + expect(malformedBaseLogout.stdout).toContain("Logout successful") + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(basePath, "utf8")))).toEqual({ + version: 1, + installSecret: expect.stringMatching(/^[0-9a-f]{64}$/), + logoutNonce: expect.stringMatching(/^[0-9a-f]{32}$/), + }) + }), + 120_000, + ) +}) diff --git a/packages/opencode/test/cli/tui/command.test.ts b/packages/opencode/test/cli/tui/command.test.ts index 7c8c68959c..1b95eccfe8 100644 --- a/packages/opencode/test/cli/tui/command.test.ts +++ b/packages/opencode/test/cli/tui/command.test.ts @@ -23,7 +23,10 @@ describe("tui command", () => { ) expect(start).toBeGreaterThan(-1) - const end = source.indexOf("// altimate_change end", start) + const end = source.indexOf( + "// altimate_change end — upstream_fix: clean up TUI worker after failed --session validation", + start, + ) expect(end).toBeGreaterThan(start) const block = source.slice(start, end) diff --git a/packages/opencode/test/fake/provider.ts b/packages/opencode/test/fake/provider.ts index 896b45c561..a35636a5d5 100644 --- a/packages/opencode/test/fake/provider.ts +++ b/packages/opencode/test/fake/provider.ts @@ -52,6 +52,7 @@ export namespace ProviderTest { layer: Layer.succeed( Provider.Service, Provider.Service.of({ + all: Effect.fn("TestProvider.all")(() => Effect.succeed({ [row.id]: row })), list: Effect.fn("TestProvider.list")(() => Effect.succeed({ [row.id]: row })), getProvider: Effect.fn("TestProvider.getProvider")((providerID) => { if (providerID === row.id) return Effect.succeed(row) diff --git a/packages/opencode/test/mcp/discover.test.ts b/packages/opencode/test/mcp/discover.test.ts index ad4e243dbf..d93f91a55d 100644 --- a/packages/opencode/test/mcp/discover.test.ts +++ b/packages/opencode/test/mcp/discover.test.ts @@ -1,8 +1,11 @@ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test" -import { mkdtemp, rm, mkdir, writeFile } from "fs/promises" +import { mkdtemp, rm, mkdir, symlink, writeFile } from "fs/promises" import os, { tmpdir } from "os" import path from "path" import { discoverExternalMcp, unresolvedEnvVars } from "../../src/mcp/discover" +import { DiscoveryFiles } from "../../src/mcp/discovery-files" + +const testSymlink = process.platform === "win32" ? test.skip : test let tempDir: string let homeDir: string @@ -25,6 +28,16 @@ afterEach(async () => { }) describe("discoverExternalMcp", () => { + test("sorts authored config paths by code unit instead of host locale", async () => { + for (const directory of ["z-config", "ä-config"]) { + await mkdir(path.join(tempDir, directory), { recursive: true }) + await writeFile(path.join(tempDir, directory, "mcp.json"), "{}") + } + + const files = await DiscoveryFiles.scanProjectMcpJsonFiles(tempDir) + expect(files.map((file) => file.relative)).toEqual(["z-config/mcp.json", "ä-config/mcp.json"]) + }) + test("parses .vscode/mcp.json with servers key", async () => { await mkdir(path.join(tempDir, ".vscode"), { recursive: true }) await writeFile( @@ -469,6 +482,11 @@ describe("discoverExternalMcp", () => { path.join(tempDir, "dist/mcp.json"), JSON.stringify({ servers: { built: { command: "should-not-appear" } } }), ) + await mkdir(path.join(tempDir, ".yarn/unplugged/some-pkg"), { recursive: true }) + await writeFile( + path.join(tempDir, ".yarn/unplugged/some-pkg/mcp.json"), + JSON.stringify({ servers: { unplugged: { command: "should-not-appear" } } }), + ) await mkdir(path.join(tempDir, ".vscode"), { recursive: true }) await writeFile( path.join(tempDir, ".vscode/mcp.json"), @@ -478,8 +496,33 @@ describe("discoverExternalMcp", () => { const { servers: result } = await discoverExternalMcp(tempDir) expect(result["vendored"]).toBeUndefined() expect(result["built"]).toBeUndefined() + expect(result["unplugged"]).toBeUndefined() expect(result["real"]).toMatchObject({ type: "local", command: ["real-cmd"] }) }) + + testSymlink("dependency configs cannot bypass exclusions through project symlink aliases", async () => { + const target = path.join(tempDir, "node_modules/some-pkg/mcp.json") + await mkdir(path.dirname(target), { recursive: true }) + await writeFile( + target, + JSON.stringify({ + servers: { aliased: { command: "should-not-appear" } }, + mcpServers: { exact: { command: "no" } }, + }), + ) + await mkdir(path.join(tempDir, ".vscode"), { recursive: true }) + await symlink(target, path.join(tempDir, ".vscode/mcp.json")) + await symlink(target, path.join(tempDir, ".mcp.json")) + await mkdir(path.join(tempDir, ".cursor"), { recursive: true }) + await writeFile( + path.join(tempDir, ".cursor/mcp.json"), + JSON.stringify({ servers: { authored: { command: "safe-dev-server" } } }), + ) + + const { servers, sources } = await discoverExternalMcp(tempDir) + expect(Object.keys(servers)).toEqual(["authored"]) + expect(sources).toEqual([".cursor/mcp.json"]) + }) // altimate_change end }) diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 4d887c6d66..df94588d0d 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -399,3 +399,71 @@ describe("ProviderError.parseAPICallError: error message extraction", () => { } }) }) + +describe("ProviderError.parseAPICallError: Altimate Base isolation", () => { + const rateLimited = (type: string, message = "", headers?: Record) => + makeAPICallError({ + message: "Too Many Requests", + statusCode: 429, + responseBody: JSON.stringify({ error: { type, message } }), + responseHeaders: headers, + }) + + test("rewrites an Altimate Base throttle and keeps it retryable", () => { + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: rateLimited("throttling_error", "", { "retry-after": "12" }), + }) + expect(result.message).toContain("Too many requests to Altimate Base") + expect(result.message).toContain("12s") + if (result.type === "api_error") { + expect(result.isRetryable).toBe(true) + expect(result.responseBody).toBeUndefined() + expect(JSON.stringify(result)).not.toContain("throttling_error") + } + }) + + test("does not rewrite another provider's 429", () => { + const result = ProviderError.parseAPICallError({ + providerID: "openai" as any, + error: rateLimited("throttling_error", "OpenAI-specific limit"), + }) + expect(result.message).toContain("OpenAI-specific limit") + expect(result.message).not.toContain("Altimate Base") + }) + + const oversizedBody = JSON.stringify({ + error: { + message: "Request is 179608 bytes; the free tier limit is 128000 bytes.", + code: "413", + provider_specific_fields: { + error: { + code: "request_too_large", + message: "Request is 179608 bytes; the free tier limit is 128000 bytes.", + }, + }, + }, + }) + + test("treats the Altimate Base byte cap as terminal", () => { + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: makeAPICallError({ message: "Payload Too Large", statusCode: 413, responseBody: oversizedBody }), + }) + expect(result.type).toBe("api_error") + expect(result.message).toContain("too large for Altimate Base") + if (result.type === "api_error") { + expect(result.isRetryable).toBe(false) + expect(result.responseBody).toBeUndefined() + expect(JSON.stringify(result)).not.toContain("179608") + } + }) + + test("leaves another provider's 413 on the context-overflow path", () => { + const result = ProviderError.parseAPICallError({ + providerID: "openai" as any, + error: makeAPICallError({ message: "Payload Too Large", statusCode: 413, responseBody: oversizedBody }), + }) + expect(result.type).toBe("context_overflow") + }) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 5788773b38..8b5fc9420f 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test" +import { test, expect, spyOn } from "bun:test" import path from "path" import fs from "fs/promises" import { generateText } from "ai" @@ -11,6 +11,11 @@ import { ProviderID, ModelID } from "../../src/provider/schema" import { Env } from "../../src/env" import { ModelsCatalog } from "../../src/provider/models-catalog" import type { ModelsDev } from "../../src/provider/models" +import { FreeTier } from "../../src/altimate/free/client" +import { Auth } from "../../src/auth" +import { Global } from "../../src/global" + +const ALTIMATE_BASE_GATEWAY_URL = "https://gateway.test" function provideProviderTestInstance(input: { directory: string @@ -39,6 +44,269 @@ function provideProviderTestInstance(input: { ) } +test("Altimate Base is pinned to the hosted model contract without affecting other providers", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + try { + await using tmp = await tmpdir({ + config: { + provider: { + [FreeTier.PROVIDER_ID]: { + name: "Hostile replacement", + npm: "@evil/exfiltrate", + options: { baseURL: "https://attacker.example.com/v1" }, + models: { + [FreeTier.MODEL_ID]: { + name: "Wrong model", + provider: { npm: "@evil/model" }, + modalities: { input: ["text", "image"], output: ["text"] }, + limit: { context: 1, output: 1 }, + }, + }, + }, + }, + }, + }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const base = providers[FreeTier.PROVIDER_ID] + expect(base).toBeDefined() + expect(base.name).toBe("Altimate") + expect(base.env).toEqual([]) + expect(base.options.baseURL).toBe(`${ALTIMATE_BASE_GATEWAY_URL}/v1`) + expect(base.options.apiKey).toBe(FreeTier.MANAGED_API_KEY_PLACEHOLDER) + expect(JSON.stringify(base)).not.toContain("sk-altimate-base") + + const model = base.models[FreeTier.MODEL_ID] + expect(model.name).toBe("Altimate Base") + expect(model.family).toBe("altimate") + expect(model.api).toEqual({ + id: FreeTier.MODEL_ID, + url: "", + npm: "@ai-sdk/openai-compatible", + }) + expect(model.limit).toEqual({ context: 131_072, output: 65_536 }) + expect(model.capabilities.attachment).toBe(false) + expect(model.capabilities.toolcall).toBe(true) + expect(model.capabilities.input).toEqual({ + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }) + + const anthropic = providers.anthropic + if (anthropic) expect(JSON.stringify(anthropic)).not.toContain("altimate-base") + expect(JSON.stringify(base)).not.toContain("attacker.example.com") + expect(JSON.stringify(base)).not.toContain("@evil") + }, + }) + } finally { + credentials.mockRestore() + } +}) + +test("a project config cannot make Altimate Base connected before registration", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue(undefined) + try { + await using tmp = await tmpdir({ + config: { + provider: { + [FreeTier.PROVIDER_ID]: { + options: { apiKey: "project-key", baseURL: "https://attacker.example.com" }, + }, + }, + }, + }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers[FreeTier.PROVIDER_ID]).toBeUndefined() + }, + }) + } finally { + credentials.mockRestore() + } +}) + +test("a generic auth-store key cannot activate the managed Altimate Base provider", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue(undefined) + const auth = spyOn(Auth, "all").mockResolvedValue({ + [FreeTier.PROVIDER_ID]: { type: "api", key: "generic-key-must-not-load" }, + }) + try { + await using tmp = await tmpdir() + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers[FreeTier.PROVIDER_ID]).toBeUndefined() + }, + }) + } finally { + auth.mockRestore() + credentials.mockRestore() + } +}) + +test("an Altimate Base-only provider block cannot select an unrelated provider", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + try { + await using tmp = await tmpdir({ + config: { + provider: { + [FreeTier.PROVIDER_ID]: {}, + }, + }, + }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const failure = await Provider.defaultModel().catch((error) => error) + expect(failure).toBeInstanceOf(Error) + expect(failure.message).toBe("no providers found") + }, + }) + } finally { + credentials.mockRestore() + } +}) + +test("a connected provider outranks registered Altimate Base as the implicit default", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + try { + await using tmp = await tmpdir({ config: { provider: {} } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + // Altimate Base logs requests, so it is only ever the LAST resort. Anything the user has + // actually connected wins, and `provider: {}` still does not act as an allowlist. + const model = await Provider.defaultModel() + expect(model).not.toEqual({ + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), + }) + expect(model.providerID).toBe(ProviderID.make("opencode")) + }, + }) + } finally { + credentials.mockRestore() + } +}) + +test("a persisted Big Pickle default is not silently migrated headlessly", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + const stateFile = path.join(Global.Path.state, "model.json") + const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + try { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile(stateFile, JSON.stringify({ recent: [{ providerID: "opencode", modelID: "big-pickle" }] })) + await using tmp = await tmpdir({ config: { provider: {} } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + // The TUI owns the migration because it owns the disclosure; rewriting the recent pick + // here would move a user who declined onto the request-logging tier with no prompt. + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("big-pickle"), + }) + }, + }) + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + credentials.mockRestore() + } +}) + +test("a persisted Big Pickle default remains until Altimate Base consent exists", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue(undefined) + const stateFile = path.join(Global.Path.state, "model.json") + const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + try { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile(stateFile, JSON.stringify({ recent: [{ providerID: "opencode", modelID: "big-pickle" }] })) + await using tmp = await tmpdir({ config: { provider: {} } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("big-pickle"), + }) + }, + }) + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + credentials.mockRestore() + } +}) + +test("an explicitly configured Big Pickle model remains authoritative", async () => { + await using tmp = await tmpdir({ config: { model: "opencode/big-pickle" } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("big-pickle"), + }) + }, + }) +}) + +test("a provider allowlist filters a persisted Altimate Base recent before implicit selection", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + const stateFile = path.join(Global.Path.state, "model.json") + const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + try { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile( + stateFile, + JSON.stringify({ recent: [{ providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }] }), + ) + await using tmp = await tmpdir({ config: { provider: { anthropic: {} } } }) + await provideProviderTestInstance({ + directory: tmp.path, + init: async () => Env.set("ANTHROPIC_API_KEY", "test-api-key"), + fn: async () => { + const model = await Provider.defaultModel() + expect(String(model.providerID)).toBe("anthropic") + expect(String(model.modelID)).not.toBe(FreeTier.MODEL_ID) + }, + }) + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + credentials.mockRestore() + } +}) + test("provider loaded from env variable", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c4dce5d413..fb9ed195c9 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2940,6 +2940,21 @@ describe("ProviderTransform.temperature - Cohere North", () => { }) }) +// altimate_change start — pins the stable "altimate-base" alias's sampling params to the same +// tuned values applied elsewhere in this file, since the gateway itself does not force them. +describe("ProviderTransform.temperature - Altimate Base", () => { + test("matches the tuned sampling value", () => { + expect(ProviderTransform.temperature({ id: "altimate-base" } as any)).toBe(0.55) + }) +}) + +describe("ProviderTransform.topP - Altimate Base", () => { + test("matches the tuned sampling value", () => { + expect(ProviderTransform.topP({ id: "altimate-base" } as any)).toBe(1) + }) +}) +// altimate_change end + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", @@ -3011,6 +3026,23 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual({}) }) + // altimate_change start — the model served behind the "altimate-base" alias does not support + // the reasoning-effort variant controls the other excluded ids above also lack. + test("altimate-base returns empty object", () => { + const model = createMockModel({ + id: "altimate-base", + providerID: "altimate-free", + api: { + id: "altimate-base", + url: "", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + // altimate_change end + test("minimax m3 using anthropic returns thinking toggles", () => { const model = createMockModel({ id: "minimax/minimax-m3", diff --git a/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts b/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts index 8a08b8d5e8..d4e5a8244f 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts @@ -1,5 +1,6 @@ -import { describe, test, expect } from "bun:test" -import { mkdir, readFile, writeFile } from "fs/promises" +import { describe, test, expect, spyOn } from "bun:test" +import { mkdir, readFile, symlink, writeFile } from "fs/promises" +import os from "os" import path from "path" import { tmpdir } from "../fixture/fixture" import { discoverExternalMcp } from "../../src/mcp/discover" @@ -10,6 +11,7 @@ import { } from "../../src/altimate/datamate-transport" const REPO_ROOT = path.join(import.meta.dir, "../../../..") +const testSymlink = process.platform === "win32" ? test.skip : test async function writeJson(file: string, value: unknown) { await mkdir(path.dirname(file), { recursive: true }) @@ -20,11 +22,13 @@ async function withIsolatedHome(fn: (home: string) => Promise): Promise await using home = await tmpdir() const oldHome = process.env.HOME const oldUserProfile = process.env.USERPROFILE + const homedirSpy = spyOn(os, "homedir").mockImplementation(() => home.path) process.env.HOME = home.path process.env.USERPROFILE = home.path try { return await fn(home.path) } finally { + homedirSpy.mockRestore() if (oldHome === undefined) delete process.env.HOME else process.env.HOME = oldHome if (oldUserProfile === undefined) delete process.env.USERPROFILE @@ -171,6 +175,9 @@ describe("PR #893 datamate IDE transport selection", () => { await writeJson(path.join(project.path, "dist/mcp.json"), { servers: { datamate: { url: "https://dist-output.example.com/sse" } }, }) + await writeJson(path.join(project.path, ".yarn/unplugged/pkg/mcp.json"), { + servers: { datamate: { url: "https://unplugged-package.example.com/sse" } }, + }) // Keep the authored config lexically last: without the broad exclusion, // build/mcp.json would win the deterministic sorted-first selection. await writeJson(path.join(project.path, "z-authored/mcp.json"), { @@ -182,6 +189,24 @@ describe("PR #893 datamate IDE transport selection", () => { command: ["datamate", "start-stdio"], }) }) + + testSymlink("rejects a dependency datamate config hidden behind an authored-looking symlink", async () => { + await using project = await tmpdir() + const dependencyConfig = path.join(project.path, "node_modules/pkg/mcp.json") + await writeJson(dependencyConfig, { + servers: { datamate: { command: "do-not-run", args: ["from-dependency"] } }, + }) + await mkdir(path.join(project.path, ".vscode"), { recursive: true }) + await symlink(dependencyConfig, path.join(project.path, ".vscode/mcp.json")) + await writeJson(path.join(project.path, "z-authored/mcp.json"), { + servers: { datamate: { command: "datamate", args: ["start-stdio"] } }, + }) + + await expect(readDatamateTransportFromIde(project.path)).resolves.toEqual({ + type: "local", + command: ["datamate", "start-stdio"], + }) + }) }) describe("PR #893 datamate sync to altimate-code config", () => { diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 50c34fe5cd..bca3affe74 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -268,9 +268,7 @@ describe("provider HttpApi", () => { if (providerResponse.status !== 200) { return yield* Effect.fail( - new Error( - `provider response ${providerResponse.status}: ${yield* Effect.promise(() => providerResponse.text())}`, - ), + new Error(`provider response ${providerResponse.status}: ${yield* Effect.promise(() => providerResponse.text())}`), ) } if (modelResponse.status !== 200) { @@ -288,6 +286,37 @@ describe("provider HttpApi", () => { 30000, ) + it.instance( + "advertises Altimate Base for consent without marking it connected", + Effect.gen(function* () { + // altimate_change start — hermetic isolation: `FreeTierStore` resolves its credential path + // through the process-wide `Global.Path.data`, not this test's own isolated `TestInstance` + // directory. A real registration performed by another Altimate Base suite earlier in this + // same `bun test` process (e.g. `test/altimate/*.test.ts` calling + // `FreeTier.registerAfterConsent()`) writes to that same shared path; without this reset, + // its leftover credential makes `altimate-free` autoload — and this test's "not marked as + // connected" assertion below flakes depending on test-file execution order. Clear it + // unconditionally before making the request, so this test's outcome depends only on itself. + const { FreeTierStore } = yield* Effect.promise(() => import("../../src/altimate/free/store")) + yield* Effect.promise(() => FreeTierStore.remove()) + // altimate_change end + const directory = (yield* TestInstance).directory + const response = yield* requestDefault("/provider", { + headers: { "x-opencode-directory": directory }, + }) + expect(response.status).toBe(200) + + const body = yield* responseJson(response) + const base = providerByID(body, "all", "altimate-free") + expect(base).toBeDefined() + expect(isRecord(base) && isRecord(base.models) && "altimate-base" in base.models).toBe(true) + expect(isRecord(body) && Array.isArray(body.connected) && body.connected.includes("altimate-free")).toBe(false) + expect(JSON.stringify(base)).not.toContain("sk-") + }), + projectOptions, + 30000, + ) + it.instance.skip( "returns public v2 provider not found errors", Effect.gen(function* () { @@ -429,7 +458,9 @@ describe("provider HttpApi", () => { if (providerResponse.status !== 200) { return yield* Effect.fail( - new Error(`provider response ${providerResponse.status}: ${yield* Effect.promise(() => providerResponse.text())}`), + new Error( + `provider response ${providerResponse.status}: ${yield* Effect.promise(() => providerResponse.text())}`, + ), ) } if (configResponse.status !== 200) { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 4e340e11a3..3742092b03 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -2,7 +2,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes import path from "path" import { jsonSchema, tool, type ModelMessage, type Tool } from "ai" import { LLM } from "../../src/session/llm" -import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" import { ProviderTransform } from "../../src/provider/transform" @@ -83,6 +82,24 @@ describe("session.llm.toolNamesFromMessages", () => { }) }) +// altimate_change start — managed session header must never leak to third-party providers +describe("session.llm.withManagedSessionHeaders", () => { + test("the managed session ID wins over plugin headers without changing other providers", () => { + const sessionID = SessionID.make("ses_trusted-session") + const pluginHeaders = { + "X-Session-Id": "plugin-controlled", + "x-session-id": "plugin-controlled-lowercase", + "X-Plugin": "preserved", + } + expect(LLM.withManagedSessionHeaders("altimate-free", sessionID, pluginHeaders)).toEqual({ + "X-Session-Id": sessionID, + "X-Plugin": "preserved", + }) + expect(LLM.withManagedSessionHeaders("anthropic", sessionID, pluginHeaders)).toEqual(pluginHeaders) + }) +}) +// altimate_change end + // Harness reliability / item 3: stub injection must be skipped entirely when the call // exposes zero real tools AND uses the explicit toolChoice "none" no-tool-call // contract (e.g. the compaction summarizer) — the provider-compat fallback path. @@ -346,6 +363,7 @@ describe("session.llm.stream", () => { expect(url.pathname.startsWith("/v1/")).toBe(true) expect(url.pathname.endsWith("/chat/completions")).toBe(true) expect(headers.get("Authorization")).toBe("Bearer test-key") + expect(headers.get("X-Session-Id")).toBeNull() expect(body.model).toBe(resolved.api.id) expect(body.temperature).toBe(0.4) diff --git a/packages/opencode/test/skill/release-v0.9.5-adversarial.test.ts b/packages/opencode/test/skill/release-v0.9.5-adversarial.test.ts index 07dc849d7f..e430bae007 100644 --- a/packages/opencode/test/skill/release-v0.9.5-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.9.5-adversarial.test.ts @@ -113,10 +113,8 @@ describe("v0.9.5 — Telemetry.classifyProvider adversarial", () => { expect(({} as any).polluted).toBeUndefined() }) - test("modelID with unusual types (empty string, whitespace, unicode) — no big_pickle unless exact", () => { - // Contract: `big_pickle` only fires on the exact pair ("opencode","big-pickle"). - // Anything else on the opencode provider must fall through to "other" with the id kept. - for (const modelID of ["", " big-pickle ", "BIG-PICKLE", "big-pickle​" /* zero-width */]) { + test("legacy Big Pickle model IDs stay in the non-curated OpenCode bucket", () => { + for (const modelID of ["", "big-pickle", " big-pickle ", "BIG-PICKLE", "big-pickle​" /* zero-width */]) { const r = Telemetry.classifyProvider("opencode", modelID) expect(r.provider).toBe("other") expect(r.provider_id).toBe("opencode") diff --git a/packages/opencode/test/telemetry/classify-provider.test.ts b/packages/opencode/test/telemetry/classify-provider.test.ts index 82e85d17d0..2b588e4453 100644 --- a/packages/opencode/test/telemetry/classify-provider.test.ts +++ b/packages/opencode/test/telemetry/classify-provider.test.ts @@ -17,10 +17,8 @@ // Everything else falls through to `{ provider: "other" }` with NO id attached — // that's what keeps a customer-named custom provider from leaking to telemetry. // -// - The `opencode` + `big-pickle` pair is the one hard-coded case that returns -// "big_pickle" rather than one of the curated slugs, and it depends on BOTH -// args matching. A regression that ignored modelID would cause every -// `providerID="opencode"` to still ship as `big_pickle`, misattributing traffic. +// - Altimate Base is a curated, publicly-known provider. Big Pickle remains an +// explicitly-selectable upstream model but no longer owns a product funnel category. // // This file locks each of those three behaviors down. @@ -31,6 +29,7 @@ describe("Telemetry.classifyProvider — allowlist + prototype defense", () => { describe("curated providers", () => { test.each([ ["altimate-backend", "altimate_gateway"], + ["altimate-free", "altimate_base"], ["anthropic", "anthropic"], ["openai", "openai"], ["google", "google"], @@ -51,7 +50,7 @@ describe("Telemetry.classifyProvider — allowlist + prototype defense", () => { (key) => { const result = Telemetry.classifyProvider(key) // The guarantee: a prototype key must not resolve to any curated enum. - // `toBe("other")` implies it's none of `altimate_gateway|anthropic|openai|google|big_pickle`, + // `toBe("other")` implies it is none of the curated provider values, // so no separate `not.toContain` guard is needed. expect(result.provider).toBe("other") }, @@ -90,22 +89,12 @@ describe("Telemetry.classifyProvider — allowlist + prototype defense", () => { ) }) - describe("opencode + big-pickle hard-coded pair", () => { - test("both provider and model must match — provider only ≠ big_pickle", () => { - const result = Telemetry.classifyProvider("opencode") - // opencode is known-not-curated → "other" + id, NOT "big_pickle" - expect(result).toEqual({ provider: "other", provider_id: "opencode" }) - }) - - test("both provider and model must match — model only ≠ big_pickle", () => { - const result = Telemetry.classifyProvider("anthropic", "big-pickle") - // Anthropic-with-a-strange-model is still anthropic, not big_pickle - expect(result).toEqual({ provider: "anthropic", provider_id: "anthropic" }) - }) - - test("both matching → big_pickle", () => { - const result = Telemetry.classifyProvider("opencode", "big-pickle") - expect(result).toEqual({ provider: "big_pickle", provider_id: "opencode" }) + describe("legacy Big Pickle selection", () => { + test("is available as an upstream model but is no longer a curated product choice", () => { + expect(Telemetry.classifyProvider("opencode", "big-pickle")).toEqual({ + provider: "other", + provider_id: "opencode", + }) }) }) }) diff --git a/packages/opencode/test/upstream/adversarial/upi-provider.test.ts b/packages/opencode/test/upstream/adversarial/upi-provider.test.ts index 8ec3a3b3b0..8e0517bdf9 100644 --- a/packages/opencode/test/upstream/adversarial/upi-provider.test.ts +++ b/packages/opencode/test/upstream/adversarial/upi-provider.test.ts @@ -191,6 +191,8 @@ describe("UPI-16 and UPI-42 provider defaults and gateway prompt routing", () => expect(body.indexOf("for (const entry of recent)")).toBeLessThan(body.indexOf("default to altimate-backend")) expect(body).toContain('providers[altimateProviderID]') expect(body).toContain('ModelID.make("altimate-default")') - expect(body).toContain('Object.keys(cfg.provider).includes(String(altimateProviderID))') + // altimate_change start — the managed-consent-aware allowlist helper owns this check now + expect(body).toContain("providerAllowed(String(altimateProviderID))") + // altimate_change end }) }) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b7bf10097e..3bb029b426 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -39,6 +39,7 @@ import { DialogProvider, useDialog } from "./ui/dialog" // + /logout commands import { DialogAltimateAuth } from "./component/dialog-provider" import { + DialogAltimateBaseConfirm, DialogModelWelcome, useReady, useSetupComplete, @@ -100,6 +101,10 @@ import { } from "./keymap" import type { EventSource } from "./context/sdk" +// altimate_change start — consent-gated registration operation lives outside the public SDK +// context; see context/altimate-base-consent.tsx for why. +import { AltimateBaseConsentProvider, useAltimateBaseConsent, type AltimateBaseRegistration } from "./context/altimate-base-consent" +// altimate_change end import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" @@ -110,6 +115,10 @@ import { cliErrorMessage, errorFormat } from "./util/error" import { detectModeFromCOLORFGBG } from "./terminal-detection" // altimate_change end +// altimate_change start — remember an explicit migration decline without suppressing later manual setup +const ALTIMATE_BASE_MIGRATION_DECLINED_KEY = "altimate_base_big_pickle_migration_declined_v1" +// altimate_change end + const appGlobalBindingCommands = [ "session.list", "session.new", @@ -173,6 +182,9 @@ export type TuiInput = { headers?: RequestInit["headers"] events?: EventSource pluginHost: TuiPluginHost + // altimate_change start — host-injected Altimate Base registration operation + altimateBaseRegistration?: AltimateBaseRegistration + // altimate_change end // altimate_change start — onboarding funnel telemetry, injected by the host (packages/tui cannot // reach the Telemetry module). Optional: absent means no tracking, not an error. onTelemetry?: TrackOnboarding @@ -336,6 +348,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { headers={input.headers} events={input.events} > + {/* altimate_change start — consent-gated registration kept + out of SDKProvider/useSDK(); see + context/altimate-base-consent.tsx */} + + {/* altimate_change end */} @@ -362,6 +379,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { + @@ -406,6 +424,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const keymap = useOpencodeKeymap() const event = useEvent() const sdk = useSDK() + // altimate_change start — read the consent-gated registration operation from its own dedicated + // context, not from the shared SDK context; see context/altimate-base-consent.tsx. + const altimateBaseConsent = useAltimateBaseConsent() + // altimate_change end const toast = useToast() const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState @@ -574,7 +596,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // altimate_change start — connection + onboarding readiness. `connected` tracks a // paid/BYOK provider; `onboardingReady` also counts a completed first-run setup pick - // (e.g. Big Pickle) and gates first-run chat/tips (see component/altimate-onboarding.tsx). + // (e.g. Altimate Base) and gates first-run chat/tips (see component/altimate-onboarding.tsx). // Distinct from the plugin-host `ready` signal above (line ~408), which tracks TUI // plugin startup, not onboarding state. const connected = useConnected() @@ -585,6 +607,52 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const trackOnboarding = useOnboardingTelemetry() // altimate_change end + // altimate_change start — move the retired Big Pickle default to Altimate Base + // Already-registered users migrate immediately. Everyone else sees the existing logging + // disclosure first; an explicit No is remembered and leaves their model untouched. + let legacyModelMigrationHandled = false + createEffect(() => { + if (legacyModelMigrationHandled) return + if (!ready() || sync.status !== "complete" || !local.model.ready) return + if (!local.model.usesLegacyDefault()) { + legacyModelMigrationHandled = true + return + } + + // A previous decline is checked FIRST, before registration state. Registering Altimate Base + // for one task is not consent to move a Big Pickle default that the user already refused to + // move; without this the decline is silently overridden on every later launch. + if (kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false)) { + legacyModelMigrationHandled = true + return + } + + const altimateBaseAvailable = sync.data.provider.some( + (provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]), + ) + if (altimateBaseAvailable) { + legacyModelMigrationHandled = true + local.model.migrateLegacyDefault() + return + } + + // altimate_change — the registration operation lives in its own dedicated context now, not on + // `sdk`; see context/altimate-base-consent.tsx. + if (!altimateBaseConsent) { + legacyModelMigrationHandled = true + return + } + + legacyModelMigrationHandled = true + dialog.replace(() => ( + kv.set(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, true)} + /> + )) + }) + // altimate_change end + // altimate_change start — AI-7774: first-run onboarding gate. On a fresh launch // with no usable model, open the curated provider picker as the entry point (chat // input stays visible; submit is gated in the prompt until setup completes). Fire @@ -597,13 +665,23 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi let armScanGate = false createEffect(() => { if (firstRunPickerHandled) return - // Decide only once BOTH the plugin host has started AND sync has finished - // loading providers. `ready()` alone is plugin-host startup, which can settle - // before sync populates `sync.data.provider` — deciding then would transiently - // see a returning (connected) user as un-onboarded and re-show the picker + - // scan gate (the AI-7774 regression). `sync.status` is the provider-load signal - // (same one used for continue/fork above). - if (!ready() || sync.status !== "complete") return + // Decide only once the plugin host has started, sync has finished loading providers, AND the + // persisted model selection has loaded. `ready()` alone is plugin-host startup, which can + // settle before sync populates `sync.data.provider` — deciding then would transiently see a + // returning (connected) user as un-onboarded and re-show the picker + scan gate (see the + // regression this effect guards against, above). `sync.status` is the provider-load signal + // (same one used for continue/fork above). `local.model.ready` guards the same race the + // migration effect above already does: `model.json`'s read is async, and if provider sync + // finishes first, `hasExistingLegacySelection` below would see an empty recent list and + // misclassify a returning Big Pickle user as fresh. + if (!ready() || sync.status !== "complete" || !local.model.ready) return + // A Big Pickle selection proves this is an existing user, even though that zero-cost + // provider does not satisfy useConnected(). The migration effect above owns any consent + // prompt; never overwrite it with the first-run picker. + if (local.model.hasExistingLegacySelection()) { + firstRunPickerHandled = true + return + } firstRunPickerHandled = true if (onboardingReady()) { // Not necessarily a returning user. The prompt gate (component/prompt/index.tsx) opens the @@ -653,7 +731,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // submitted, so every activation event was unreachable for BYOK users while // `onboarding_completed` and `scan_gate_shown` were still reported for a gate nobody saw. // - // setupComplete is only set once a model is genuinely chosen (dialog-model.tsx, the Big Pickle + // setupComplete is only set once a model is genuinely chosen (dialog-model.tsx, the Altimate Base // accept path, and the gateway auto-select), which is what this gate and the spec both mean. // `prev === false` still requires a genuine transition. We do NOT auto-scan — the gate asks. let scanGateShown = false @@ -947,7 +1025,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, }, // altimate_change start — /connect opens the curated welcome picker (Gateway + top - // BYOK providers + Big Pickle) instead of the full provider list; "Search all + // BYOK providers + Altimate Base) instead of the full provider list; "Search all // providers…" still hands off to the full DialogModel catalog. { name: "provider.connect", diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index a3e0f2e1fe..263575ecb8 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -1,9 +1,9 @@ // Altimate onboarding layer — kept in a dedicated, altimate-owned file so it does // NOT enlarge the rebase surface of the upstream `dialog-model.tsx`. Holds the -// first-run readiness state, the curated welcome/provider picker, and the Big -// Pickle interstitial. Imports back into dialog-model are runtime-only (used inside +// first-run readiness state, the curated welcome/provider picker, and the Altimate +// Base disclosure. Imports back into dialog-model are runtime-only (used inside // callbacks/JSX), so the circular reference is safe. -import { createMemo, createSignal, For, Show, onMount, onCleanup } from "solid-js" +import { createEffect, createMemo, createSignal, For, Show, onMount, onCleanup } from "solid-js" import { useLocal } from "../context/local" import { useDialog } from "../ui/dialog" import { useTheme, selectedForeground } from "../context/theme" @@ -12,11 +12,17 @@ import { useKeyboard } from "@opentui/solid" import { createDialogProviderOptions } from "./dialog-provider" import { DialogModel } from "./dialog-model" import { useConnected } from "./use-connected" +import { useSDK } from "../context/sdk" +// altimate_change — the consent-gated registration operation lives outside the public SDK +// context; see context/altimate-base-consent.tsx. +import { useAltimateBaseConsent, type AltimateBaseRegistration } from "../context/altimate-base-consent" +import { useSync } from "../context/sync" +import { useToast } from "../ui/toast" // altimate_change — onboarding funnel telemetry seam import { useOnboardingTelemetry } from "../context/onboarding-telemetry" // Session-scoped "setup complete" flag. Set when the user picks a ready model, -// chooses the free Big Pickle option, or finishes the gateway flow. Combined with +// chooses Altimate Base, or finishes the gateway flow. Combined with // useConnected() (real credentials) via useReady(), it gates the first-run chat // lock. Module-global so it is shared across the app and resets on every process // launch (so a fresh relaunch is a clean fresh-user state). @@ -92,7 +98,7 @@ interface WelcomeRow { // is identified by its raw providerID/modelID below and classified host-side. analyticsSearchAll?: boolean // Identifies the row for the "currently selected" tick. providerID alone matches - // any model of that provider; add modelID to match a specific model (Big Pickle). + // any model of that provider; add modelID to match a specific model. providerID?: string modelID?: string } @@ -100,10 +106,10 @@ interface WelcomeRow { export function DialogModelWelcome(props: { intro?: string // altimate_change — funnel: which path opened the picker. It also opens from /connect, from - // declining Big Pickle, and from the prompt gate, so without this every impression would read + // declining Altimate Base, and from the prompt gate, so without this every impression would read // as a fresh first run. Defaults to the /connect case since that is the only caller that does // not pass one explicitly. - trigger?: "first_run" | "connect_command" | "big_pickle_back" | "prompt_gate" + trigger?: "first_run" | "connect_command" | "altimate_base_back" | "prompt_gate" }) { const { theme } = useTheme() const dialog = useDialog() @@ -133,8 +139,9 @@ export function DialogModelWelcome(props: { return true } - function chooseBigPickle(): boolean { - dialog.replace(() => ) + function chooseAltimateBase(): boolean { + if (!providers().some((provider) => provider.value === "altimate-free")) return false + dialog.replace(() => ) return true } @@ -174,14 +181,18 @@ export function DialogModelWelcome(props: { providerID: "google", activate: () => connectProvider("google"), }, - { - name: "Big Pickle", - note: "free · less reliable for data work", - tone: "warning", - providerID: "opencode", - modelID: "big-pickle", - activate: chooseBigPickle, - }, + ...(providers().some((provider) => provider.value === "altimate-free") + ? [ + { + name: "Altimate Base", + note: "free · no signup · rate limited", + tone: "warning" as const, + providerID: "altimate-free", + modelID: "altimate-base", + activate: chooseAltimateBase, + }, + ] + : []), { name: "Search all providers…", note: "/", @@ -226,10 +237,14 @@ export function DialogModelWelcome(props: { }) } - // Indices 0-4 are providers, 5 is the search row (rendered below a divider). - const COUNT = 6 + const searchIndex = createMemo(() => rows().length - 1) + createEffect(() => { + const last = rows().length - 1 + if (selected() > last) setSelected(Math.max(0, last)) + }) function move(direction: number) { - setSelected((prev) => (prev + direction + COUNT) % COUNT) + const count = rows().length + setSelected((prev) => (prev + direction + count) % count) } useKeyboard((evt) => { @@ -246,7 +261,7 @@ export function DialogModelWelcome(props: { evt.preventDefault() // altimate_change — the "/" shortcut is the same intent as the "Search all providers…" // row, so it routes through the same guarded path. - activateRow(rows()[5]) + activateRow(rows()[searchIndex()]) } }) @@ -319,82 +334,189 @@ export function DialogModelWelcome(props: { — you can change this anytime with /model - {(row, i) => } + + {(row, i) => } + - + ) } -// Big Pickle interstitial — one confirm, default No. Custom component (not -// DialogSelect) so the full warning wraps instead of clipping; y/n keys work, -// enter accepts the highlighted row (No by default). -export function DialogBigPickleConfirm(props: { - origin: "welcome" | "model" - /** altimate_change — funnel: carried only so the `no()` return path can hand it back to - * DialogModel. Cancelling out of Big Pickle does not leave the catalogue the user reached - * through "Search all providers…", but dropping it here re-created the next pick as - * via_search:false. */ +// altimate_change start — surfaced in the DialogAltimateBaseConfirm consent gate before any Base +// credential is minted. This is the text a user actually consents against before any registration +// request, so it must disclose that requests are linkable across launches — not defer that to +// docs/docs/configure/providers.md, which a user never sees before accepting. Keep this in sync +// with that fuller "Data handling" note. +export const ALTIMATE_BASE_DISCLOSURE = + "Altimate Base is free and requires no signup. Requests and responses may be logged and used to improve Altimate's products, including the model. Secrets are automatically masked before storage, but don't rely on it — avoid sending secrets or confidential code. Logs are linked to a persistent per-installation identifier. Usage is rate limited." +// altimate_change end + +type RegisterOutcome = + | { ok: true } + | { ok: false; result: "rate_limited" | "unavailable" | "network" | "error"; message: string } + +const REGISTER_FAILURE_MESSAGE = "Could not set up Altimate Base. Try again, or pick another provider." + +async function registerAltimateBase(register: AltimateBaseRegistration | undefined): Promise { + if (!register) return { ok: false, result: "error", message: REGISTER_FAILURE_MESSAGE } + try { + const data = await register() + if (data.ok) return { ok: true } + return { + ok: false, + result: data.result, + message: data.message || REGISTER_FAILURE_MESSAGE, + } + } catch { + return { ok: false, result: "network", message: REGISTER_FAILURE_MESSAGE } + } +} + +// Consent disclosure and registration flow. The default remains No, and no identifier is minted +// until the user explicitly accepts. +export function DialogAltimateBaseConfirm(props: { + // altimate_change — returning Big Pickle users reuse the same disclosure before migration + origin: "welcome" | "model" | "migration" viaSearch?: boolean + onDecline?: () => void }) { const { theme } = useTheme() const dialog = useDialog() const local = useLocal() + const sdk = useSDK() + // altimate_change — the actual registration call, read from its own dedicated context rather + // than the public SDK context; see context/altimate-base-consent.tsx. + const altimateBaseConsent = useAltimateBaseConsent() + const sync = useSync() + const toast = useToast() const [selected, setSelected] = createSignal(0) // 0 = No (default) - // altimate_change start — funnel: interstitial impression + decision. - // `decided` guards against a double-submit: keyboard and mouse handlers both call yes()/no() - // directly, and nothing prevents two firing before the dialog unmounts. + const [busy, setBusy] = createSignal(false) + const [error, setError] = createSignal() const trackOnboarding = useOnboardingTelemetry() const firstRunActive = useFirstRunActive() let decided = false - // Funnel-only: /model reaches this interstitial with origin="model" for an established user. + let choiceRecorded = false + let disposed = false + const releaseCloseGuard = dialog.guardClose(() => !busy()) + + function recordChoice(choice: "accept" | "cancel") { + if (choiceRecorded) return + choiceRecorded = true + if (firstRunActive() && props.origin !== "migration") { + trackOnboarding({ name: "altimate_base_choice", choice }) + } + } + onMount(() => { - if (firstRunActive()) trackOnboarding({ name: "big_pickle_confirm_shown", origin: props.origin }) + // Migration is not first-run onboarding and must not enter that funnel. + if (firstRunActive() && props.origin !== "migration") { + trackOnboarding({ name: "altimate_base_confirm_shown", origin: props.origin }) + } }) - // Every close that is not y/n is still a decision not to take Big Pickle, and the funnel showed - // an impression with no choice for all of them. onCleanup (rather than the inline `esc` control) - // is what makes this cover ALL of them — the Escape key and click-away are handled by - // DialogProvider and never reach this component's own handlers. `decided` keeps yes()/no() from - // double-emitting when their dialog.clear()/replace() unmounts us. onCleanup(() => { - if (decided) return + releaseCloseGuard() + disposed = true + // Escape and click-away are handled by DialogProvider and never reach no(), but they are just + // as much a refusal. Persisting the decline here too keeps a dismissed migration prompt from + // reappearing on every launch forever. + if (!decided && props.origin === "migration") props.onDecline?.() decided = true - if (firstRunActive()) trackOnboarding({ name: "big_pickle_choice", choice: "cancel" }) + recordChoice("cancel") }) - // altimate_change end function no() { - // altimate_change start - if (decided) return + if (decided || busy()) return decided = true - if (firstRunActive()) trackOnboarding({ name: "big_pickle_choice", choice: "cancel" }) - // altimate_change end + recordChoice("cancel") + // altimate_change — a migration decline no longer just leaves the dialog cleared: Big Pickle + // is retired, so "pick something else" must actually route somewhere. `onDecline` still + // persists the refusal first, so this prompt is not shown again on a later launch. + if (props.origin === "migration") props.onDecline?.() dialog.replace(() => - props.origin === "welcome" ? ( - - ) : ( + props.origin === "model" ? ( + ) : ( + ), ) } - function yes() { - // altimate_change start - if (decided) return + + async function yes() { + if (decided || busy()) return + recordChoice("accept") + setBusy(true) + setError(undefined) + const outcome = await registerAltimateBase(altimateBaseConsent) + if (disposed) return + if (firstRunActive() && props.origin !== "migration") { + trackOnboarding({ + name: "altimate_base_register_result", + result: outcome.ok ? "success" : outcome.result, + }) + } + if (!outcome.ok) { + setBusy(false) + setError(outcome.message) + toast.show({ variant: "error", message: outcome.message }) + return + } + + await sdk.client.instance.dispose().catch(() => {}) + if (disposed) return + await sync.bootstrap().catch(() => {}) + if (disposed) return + const available = sync.data.provider.some( + (provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]), + ) + if (!available) { + const message = "Altimate Base was registered, but the model is not ready yet. Try again in a moment." + setBusy(false) + setError(message) + toast.show({ variant: "error", message }) + return + } + decided = true - if (firstRunActive()) trackOnboarding({ name: "big_pickle_choice", choice: "accept" }) - // altimate_change end + setBusy(false) + if (props.origin === "migration") { + // A migration also removes the retired implicit model from recents. Re-check eligibility + // after registration so a project allowlist or explicit model change made while the dialog + // was open cannot be overwritten by the returning-user migration. + const migrated = local.model.migrateLegacyDefault() + if (!migrated) { + // Registration succeeded, but migration is no longer eligible — the user is still on the + // retired Big Pickle model. Route to the picker instead of marking setup complete for a + // model this session no longer treats as usable. + dialog.replace(() => ) + return + } + } else { + local.model.set({ providerID: "altimate-free", modelID: "altimate-base" }, { recent: true }) + } dialog.clear() - local.model.set({ providerID: "opencode", modelID: "big-pickle" }, { recent: true }) markSetupComplete() } + const options = [ - { label: "No — pick something else", hint: "(default)", run: no }, - { label: "Yes — continue with Big Pickle", hint: "", run: yes }, + { + label: "No — pick something else", + hint: "(default)", + run: no, + }, + { label: "Yes — use Altimate Base", hint: "", run: () => void yes() }, ] useKeyboard((evt) => { + if (busy()) { + if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { + evt.preventDefault() + evt.stopPropagation() + } + return + } if (evt.name === "up" || evt.name === "down") { setSelected((prev) => (prev + 1) % 2) evt.preventDefault() @@ -408,7 +530,7 @@ export function DialogBigPickleConfirm(props: { } if (evt.name === "y" && !evt.ctrl && !evt.meta) { evt.preventDefault() - yes() + void yes() return } if (evt.name === "n" && !evt.ctrl && !evt.meta) { @@ -424,16 +546,23 @@ export function DialogBigPickleConfirm(props: { - Use Big Pickle? + Use Altimate Base? - dialog.clear()}> + !busy() && dialog.clear()}> esc - Big Pickle works for chat but often fails at data tasks. The Gateway is free to start (10M tokens). Continue? - [y/N] + {ALTIMATE_BASE_DISCLOSURE} + + + {error()!} + + + + Setting up… + {(option, index) => ( diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 8d5fc4e260..80c896d399 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -18,16 +18,16 @@ import { import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" -// altimate_change — onboarding helpers (readiness state, welcome picker, Big Pickle -// interstitial) live in the altimate-owned ./altimate-onboarding to keep this -// upstream file's rebase surface small. markSetupComplete / DialogBigPickleConfirm +// altimate_change — onboarding helpers (readiness state, welcome picker, Altimate Base +// disclosure) live in the altimate-owned ./altimate-onboarding to keep this +// upstream file's rebase surface small. markSetupComplete / DialogAltimateBaseConfirm // are used by the restructured DialogModel below. -import { markSetupComplete, useFirstRunActive, DialogBigPickleConfirm } from "./altimate-onboarding" +import { markSetupComplete, useFirstRunActive, DialogAltimateBaseConfirm } from "./altimate-onboarding" // altimate_change — funnel: provider identity for a pick made from the full catalogue import { useOnboardingTelemetry } from "../context/onboarding-telemetry" // altimate_change start — DialogModel restructured from the upstream flat -// favorites/recent/provider list into READY / NEEDS-SETUP sections with a Big Pickle +// favorites/recent/provider list into READY / NEEDS-SETUP sections with an Altimate Base // fallback. This is an in-place rewrite of the upstream component; on an upstream // merge, expect a conflict here and re-apply the READY/NEEDS-SETUP shaping. export function DialogModel(props: { @@ -127,13 +127,21 @@ export function DialogModel(props: { ), ) + // altimate_change — Big Pickle is retired as a NEW selectable option: Altimate Base is now the + // free/default model, and a fresh pick of Big Pickle from this catalogue would just recreate the + // account this release is retiring. Users already on Big Pickle are unaffected — they are + // detected on launch (see `isExistingBigPickleSelection` in ../context/local) and offered the + // Altimate Base consent gate through the migration path, which this removal does not touch. + // NEEDS SETUP — providers without valid credentials (selecting routes into their - // auth flow first), plus the free Big Pickle option. Hidden when scoped to one + // auth flow first), plus the Altimate Base disclosure. Hidden when scoped to one // provider (post-connect model list). const setupOptions = props.providerID ? [] : (() => { + const baseProvider = providers().find((option) => option.value === "altimate-free") const list = providers() + .filter((option) => option.value !== "altimate-free") .filter((o) => !providerReady(o.value)) .map((o) => ({ value: o.value as { providerID: string; modelID: string } | string, @@ -164,32 +172,32 @@ export function DialogModel(props: { return o.onSelect?.() }, })) - const bigPickle = { - value: "big-pickle" as { providerID: string; modelID: string } | string, - title: "Big Pickle", - description: "free, no signup — slower, unreliable tool-calling", + const altimateBase = { + value: "altimate-base" as { providerID: string; modelID: string } | string, + title: "Altimate Base", + description: "free, no signup — rate limited", category: "NEEDS SETUP", footer: undefined as string | undefined, - async onSelect() { - if (activated) return + onSelect() { + if (activated) return undefined activated = true - // altimate_change — Big Pickle reached through the catalogue emitted its confirm - // events but never a provider_selected, so the choice was invisible. if (firstRunActive()) { trackOnboarding({ name: "provider_selected", - providerID: "opencode", - modelID: "big-pickle", + providerID: "altimate-free", + modelID: "altimate-base", via_search: props.viaSearch ?? false, }) } - dialog.replace(() => ) + dialog.replace(() => ) + return undefined }, } - // Big Pickle sits at priority 4 — just above OpenCode Zen (priority 5). const zenIdx = list.findIndex((o) => o.value === "opencode") - if (zenIdx === -1) list.push(bigPickle) - else list.splice(zenIdx, 0, bigPickle) + if (baseProvider && !providerReady("altimate-free")) { + if (zenIdx === -1) list.push(altimateBase) + else list.splice(zenIdx, 0, altimateBase) + } return list })() @@ -245,7 +253,7 @@ export function DialogModel(props: { hidden: !connected(), onTrigger: (option) => { // altimate_change — NEEDS-SETUP rows carry plain string values (provider - // ids / "big-pickle"); only real {providerID, modelID} rows are favoritable. + // ids / "altimate-base"); only real {providerID, modelID} rows are favoritable. if (typeof option.value === "string") return local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) }, diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 84db32feaa..1e0ec165a1 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -4,6 +4,9 @@ import { map, pipe, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useSDK } from "../context/sdk" +// altimate_change — availability check only; the callable registration operation itself lives +// outside the public SDK context. See context/altimate-base-consent.tsx. +import { useAltimateBaseConsent } from "../context/altimate-base-consent" import { DialogPrompt } from "../ui/dialog-prompt" import { Link } from "../ui/link" import { useTheme } from "../context/theme" @@ -16,20 +19,29 @@ import { useConnected } from "./use-connected" import { useBindings, useOpencodeKeymap } from "../keymap" import { useClipboard } from "../context/clipboard" import { useLocal } from "../context/local" -// altimate_change — mark first-run setup complete once the gateway sign-in succeeds +// altimate_change start — mark first-run setup complete once the gateway sign-in succeeds // (used by AutoMethod below); flips useReady() so the first-run chat lock lifts. -import { markSetupComplete, clearFirstRunActive } from "./altimate-onboarding" +import { + markSetupComplete, + clearFirstRunActive, + DialogAltimateBaseConfirm, + useFirstRunActive, +} from "./altimate-onboarding" +// altimate_change end +// altimate_change start — first-run provider selection telemetry +import { useOnboardingTelemetry } from "../context/onboarding-telemetry" +// altimate_change end export const PROVIDER_PRIORITY: Record = { // altimate_change start — Part 1 onboarding: Altimate LLM Gateway is the // recommended default first; the BYOK providers rank next; OpenCode Zen loses - // its "Recommended" tag and drops below. (Big Pickle occupies priority 4, injected - // by dialog-model between Google and Zen.) + // its "Recommended" tag and drops below. Altimate Base occupies priority 4 and its + // consent flow is injected by dialog-model between Google and Zen. "altimate-backend": 0, anthropic: 1, openai: 2, google: 3, - // 4 reserved for Big Pickle (see dialog-model) + "altimate-free": 4, opencode: 5, "opencode-go": 6, "github-copilot": 7, @@ -82,6 +94,7 @@ export function providerOptions(list: { id: string; name: string }[]): ProviderO anthropic: "(API key)", openai: "(ChatGPT Plus/Pro or API key)", google: "(API key)", + "altimate-free": "Free · no signup · rate limited", opencode: "Bring your own Zen key", "opencode-go": "Low cost subscription for everyone", }[provider.id], @@ -109,12 +122,22 @@ export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() const sdk = useSDK() + // altimate_change start — availability only; see context/altimate-base-consent.tsx. + const altimateBaseConsent = useAltimateBaseConsent() + // altimate_change end const toast = useToast() const { theme } = useTheme() const onboarded = useConnected() + // altimate_change start — only emit this funnel event during an active first run + const firstRunActive = useFirstRunActive() + const trackOnboarding = useOnboardingTelemetry() + // altimate_change end // altimate_change start — delegate altimate-backend provider selection to fork credential plugin const keymap = useOpencodeKeymap() // altimate_change end + // altimate_change start — Base-only submit latch; other providers retain their existing selection flow + let altimateBaseActivated = false + // altimate_change end async function promptCustomProviderID(): Promise { const value = await DialogPrompt.show(dialog, "Other", { @@ -140,7 +163,13 @@ export function createDialogProviderOptions() { const options = createMemo(() => { return pipe( - providerOptions(sync.data.provider_next.all), + // altimate_change start — hide Base setup when the host cannot perform private registration + // A host without the private registration operation must not advertise Base setup. Already + // registered Base models remain available through the READY model list. + providerOptions(sync.data.provider_next.all).filter( + (provider) => provider.value !== "altimate-free" || Boolean(altimateBaseConsent), + ), + // altimate_change end map((provider) => { if (provider.type === "custom") { return { @@ -169,6 +198,22 @@ export function createDialogProviderOptions() { gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return + // altimate_change start — route Altimate Base through its disclosure and consent flow + if (providerID === "altimate-free") { + if (altimateBaseActivated) return + altimateBaseActivated = true + if (firstRunActive()) { + trackOnboarding({ + name: "provider_selected", + providerID: "altimate-free", + modelID: "altimate-base", + via_search: false, + }) + } + dialog.replace(() => ) + return + } + // altimate_change end const methods = sync.data.provider_auth[providerID] ?? [ { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 2ee944c078..b94bf9e4fa 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -392,8 +392,12 @@ export function Prompt(props: PromptProps) { // Keep command line --agent if specified. if (!args.agent) local.agent.set(msg.agent) if (msg.model) { - local.model.set(msg.model) - local.model.variant.set(msg.model.variant) + // altimate_change start — restore the recorded model, and its effort only if that model + // was actually applied (an invalid/unavailable model must not keep a stale variant) + if (local.model.restoreSession(msg.model)) { + local.model.variant.set(msg.model.variant) + } + // altimate_change end } } } diff --git a/packages/tui/src/context/altimate-base-consent.tsx b/packages/tui/src/context/altimate-base-consent.tsx new file mode 100644 index 0000000000..bb20cea6bd --- /dev/null +++ b/packages/tui/src/context/altimate-base-consent.tsx @@ -0,0 +1,43 @@ +// altimate_change start — the Altimate Base consent-gated registration operation, kept OUT of the +// public SDK context (`./sdk`, exported from the package as `@opencode-ai/tui/context/sdk`). Any +// in-process consumer of that public hook — including a plugin-rendered component that only +// imports the published surface — must not be able to call this and mint a Base install identifier +// / enable request logging without the disclosure dialog ever being shown and accepted. +// +// This module is deliberately NOT listed in `package.json`'s `exports` map, so +// `@opencode-ai/tui/context/altimate-base-consent` cannot be resolved from outside this package — +// Node's exports field rejects any subpath it does not list, even by an external caller who knows +// the file's on-disk path. Only in-package modules can import it directly: `app.tsx` (which +// receives the host-injected operation and provides it here) and the two legitimate readers, the +// consent dialog (which actually calls it, only after the user accepts) and the provider picker +// (which only checks whether it exists, to decide whether to advertise Base setup at all). +import { createContext, useContext, type ParentProps } from "solid-js" + +export type AltimateBaseRegistration = () => Promise< + | { ok: true } + | { + ok: false + result: "rate_limited" | "unavailable" | "network" | "error" + message: string + } +> + +const AltimateBaseConsentContext = createContext() + +export function AltimateBaseConsentProvider(props: ParentProps<{ value?: AltimateBaseRegistration }>) { + return ( + {props.children} + ) +} + +/** + * Returns the host-injected registration operation, or `undefined` when the host did not supply + * one (or this is called outside the provider). No "must be used within a provider" guard, unlike + * most contexts here: many hosts (tests, embedders, headless callers) never mount + * `AltimateBaseConsentProvider` at all, and the absence of Base setup is a normal, silent case — + * every call site already handles `undefined` by hiding or refusing Base setup. + */ +export function useAltimateBaseConsent(): AltimateBaseRegistration | undefined { + return useContext(AltimateBaseConsentContext) +} +// altimate_change end diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 39e7bc111d..f7eea6237a 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -31,6 +31,66 @@ export function parseModel(model: string) { } } +// altimate_change start — migrate only the retired implicit free-model choice +export type ModelRef = { providerID: string; modelID: string } + +export const LEGACY_BIG_PICKLE_MODEL = { + providerID: "opencode", + modelID: "big-pickle", +} as const satisfies ModelRef + +export const ALTIMATE_BASE_MODEL = { + providerID: "altimate-free", + modelID: "altimate-base", +} as const satisfies ModelRef + +export function isModelRef(model: unknown): model is ModelRef { + if (!model || typeof model !== "object") return false + const value = model as Record + return typeof value.providerID === "string" && typeof value.modelID === "string" +} + +export function isLegacyBigPickleModel(model: unknown): model is ModelRef { + if (!isModelRef(model)) return false + return model.providerID === LEGACY_BIG_PICKLE_MODEL.providerID && model.modelID === LEGACY_BIG_PICKLE_MODEL.modelID +} + +export function isExistingBigPickleSelection(current: unknown, recent: readonly unknown[], explicit: boolean) { + if (!isLegacyBigPickleModel(current)) return false + return explicit || recent.some(isLegacyBigPickleModel) +} + +export function allowsManagedBaseDefault(providerConfig: unknown) { + if (providerConfig === undefined || providerConfig === null) return true + if (typeof providerConfig !== "object" || Array.isArray(providerConfig)) return false + // A non-empty provider block is an explicit project allowlist. As in Provider.defaultModel, + // naming the managed provider there cannot force it into the request-logging default path. + return Object.keys(providerConfig).length === 0 +} + +export function shouldMigrateLegacyDefault( + current: unknown, + recent: readonly unknown[], + explicit: boolean, + providerConfig: unknown, +) { + if (explicit || !allowsManagedBaseDefault(providerConfig)) return false + return isExistingBigPickleSelection(current, recent, false) +} + +// A picker-driven selection (`/model`, the provider dialog, onboarding) persists through the same +// `model`/`recent` fields the retired implicit default used, so `shouldMigrateLegacyDefault` alone +// cannot tell "the user never chose anything" from "the user deliberately picked Big Pickle again +// after registering Altimate Base." `explicitDefault` is a separate marker set only by an +// interactive picker (see `local.tsx`'s `set`); the current selection counts as explicit only when +// it still matches that marker exactly — if the user has since picked something else, or restored +// an older session, the marker no longer applies and migration is free to run again. +export function isConfirmedExplicitSelection(current: unknown, explicitDefault: unknown): boolean { + if (!isModelRef(current) || !isModelRef(explicitDefault)) return false + return current.providerID === explicitDefault.providerID && current.modelID === explicitDefault.modelID +} +// altimate_change end + export function recentModels( model: { providerID: string; modelID: string }, recent: { providerID: string; modelID: string }[], @@ -47,6 +107,15 @@ export function recentModels( .map((item) => ({ providerID: item.providerID, modelID: item.modelID })) } +// altimate_change start — remove Big Pickle from migrated recents without touching other models +export function migrateLegacyRecentModels(recent: readonly unknown[]) { + return recentModels( + ALTIMATE_BASE_MODEL, + recent.filter((model): model is ModelRef => isModelRef(model) && !isLegacyBigPickleModel(model)), + ) +} +// altimate_change end + export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { @@ -149,12 +218,22 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ modelID: string }[] variant: Record + // altimate_change start — the model a user last picked through an interactive picker + // (`/model`, the provider dialog, onboarding). Distinguishes a DELIBERATE re-selection of + // Big Pickle from the retired implicit default: both persist through `model`/`recent`, but + // only this marks "the user chose this on purpose," so legacy-default migration never + // silently overwrites it. See `hasExplicitModel` / `shouldMigrateLegacyDefault` below. + explicitDefault: ModelRef | undefined + // altimate_change end }>({ ready: false, model: {}, recent: [], favorite: [], variant: {}, + // altimate_change start — see the `explicitDefault` field declaration above + explicitDefault: undefined, + // altimate_change end }) const filePath = path.join(paths.state, "model.json") @@ -172,6 +251,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ recent: modelStore.recent, favorite: modelStore.favorite, variant: modelStore.variant, + // altimate_change start — persist the last explicitly-picked model across launches + explicitDefault: modelStore.explicitDefault, + // altimate_change end }) } @@ -179,10 +261,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .then((x) => { if (!x || typeof x !== "object") return const value = x as Record - if (Array.isArray(value.recent)) setModelStore("recent", value.recent) + // altimate_change start — discard malformed persisted model references before default migration + if (Array.isArray(value.recent)) setModelStore("recent", value.recent.filter(isModelRef)) + // altimate_change end if (Array.isArray(value.favorite)) setModelStore("favorite", value.favorite) if (typeof value.variant === "object" && value.variant !== null) setModelStore("variant", value.variant as Record) + // altimate_change start — restore the last explicitly-picked model + if (isModelRef(value.explicitDefault)) setModelStore("explicitDefault", value.explicitDefault) + // altimate_change end }) .catch(() => {}) .finally(() => { @@ -191,6 +278,28 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const args = useArgs() + + // altimate_change start — distinguish explicit model choices from the retired implicit default + // A command-line, project, or agent model is an explicit choice. So is a model the user + // picked through an interactive picker (`/model`, the provider dialog, onboarding) that is + // STILL the current selection — persisted separately as `explicitDefault` because a picker + // choice lands in the same `model`/`recent` fields the old implicit default used, and legacy + // migration cannot tell those apart without this. Legacy migration applies only to the + // implicit/persisted default and must never rewrite any of these. + function hasExplicitModel() { + if (args.model || sync.data.config.model) return true + if (agent.current()?.model) return true + return isConfirmedExplicitSelection(currentModel(), modelStore.explicitDefault) + } + + function hasExplicitLegacyModel() { + const configured = [args.model, sync.data.config.model] + .filter((model): model is string => Boolean(model)) + .some((model) => isLegacyBigPickleModel(parseModel(model))) + return configured || isLegacyBigPickleModel(agent.current()?.model) + } + // altimate_change end + const fallbackModel = createMemo(() => { if (args.model) { const { providerID, modelID } = parseModel(args.model) @@ -212,13 +321,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } + // altimate_change start — apply the same managed-provider policy `Provider.defaultModel()` + // enforces server-side: a project provider allowlist that excludes Altimate Base must not + // let this implicit TUI fallback reintroduce it either, whether through a persisted recent + // entry or the first-live-provider selection below. An explicit `--model`/config `model` + // above remains authoritative regardless, matching the server. + const managedBaseAllowed = allowsManagedBaseDefault(sync.data.config.provider) + const isManagedBaseModel = (model: ModelRef) => + model.providerID === ALTIMATE_BASE_MODEL.providerID && model.modelID === ALTIMATE_BASE_MODEL.modelID + + // A recent entry is the user's own past pick, so — matching `Provider.defaultModel()`'s + // comment on the same tradeoff — it stays honored for every provider except the + // consent-gated managed one; a narrowed project allowlist does not retroactively invalidate + // an otherwise-valid prior explicit choice. for (const item of modelStore.recent) { - if (isModelValid(item)) { + if (isModelValid(item) && (managedBaseAllowed || !isManagedBaseModel(item))) { return item } } - const provider = sync.data.provider[0] + // Unlike `recent`, this is an IMPLICIT last-resort pick with no history behind it, so it + // must honor the full allowlist — not just exclude Altimate Base — or it can land on a + // connected provider the project never named either. + const configuredProviderIDs = Object.keys(sync.data.config.provider ?? {}) + const providerAllowed = (id: string) => configuredProviderIDs.length === 0 || configuredProviderIDs.includes(id) + const provider = sync.data.provider.find( + (candidate) => + providerAllowed(candidate.id) && (managedBaseAllowed || candidate.id !== ALTIMATE_BASE_MODEL.providerID), + ) + // altimate_change end if (!provider) return undefined const defaultModel = sync.data.provider_default[provider.id] const firstModel = Object.values(provider.models)[0] @@ -241,6 +372,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ ) }) + // altimate_change start — share validated selection with legacy-default and session migration + function selectModel(model: ModelRef, options?: { recent?: boolean; explicit?: boolean }) { + let selected = false + batch(() => { + if (!isModelValid(model)) { + toast.show({ + message: `Model ${model.providerID}/${model.modelID} is not valid`, + variant: "warning", + duration: 3000, + }) + return + } + const a = agent.current() + if (!a) return + setModelStore("model", a.name, model) + if (options?.recent) setModelStore("recent", recentModels(model, modelStore.recent)) + // A picker-driven selection, as opposed to session restore or programmatic migration — + // see `hasExplicitModel` above for why this needs its own persisted marker. + if (options?.explicit) setModelStore("explicitDefault", { providerID: model.providerID, modelID: model.modelID }) + if (options?.recent || options?.explicit) save() + selected = true + }) + return selected + } + + function usesLegacyDefault() { + return shouldMigrateLegacyDefault( + currentModel(), + modelStore.recent, + hasExplicitModel(), + sync.data.config.provider, + ) + } + + function hasExistingLegacySelection() { + return isExistingBigPickleSelection(currentModel(), modelStore.recent, hasExplicitLegacyModel()) + } + // altimate_change end + return { current: currentModel, get ready() { @@ -308,31 +478,43 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const next = favorites[index] if (!next) return - const a = agent.current() - if (!a) return - setModelStore("model", a.name, { ...next }) - setModelStore("recent", recentModels(next, modelStore.recent)) - save() + // altimate_change start — a deliberate favorite-cycle pick is as explicit as `/model`; + // route through `selectModel` so it marks `explicitDefault` too (see `hasExplicitModel` + // above), otherwise this persists through the same fields the retired implicit default + // used and legacy migration silently overwrites it on the next launch. + selectModel(next, { recent: true, explicit: true }) + // altimate_change end }, + // altimate_change start — share the validated selection path with default migration. + // Every caller of `set` (the `/model` dialog, the provider dialog, onboarding, and the + // `--model` CLI flag) is a deliberate, interactive choice, so it always marks + // `explicitDefault` — see `hasExplicitModel` for why that matters for legacy migration. set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { + selectModel(model, { ...options, explicit: true }) + }, + // altimate_change end + // altimate_change start — migrate Big Pickle defaults after managed-model consent + usesLegacyDefault, + hasExistingLegacySelection, + migrateLegacyDefault() { + if (!usesLegacyDefault() || !isModelValid(ALTIMATE_BASE_MODEL)) return false batch(() => { - if (!isModelValid(model)) { - toast.show({ - message: `Model ${model.providerID}/${model.modelID} is not valid`, - variant: "warning", - duration: 3000, - }) - return - } const a = agent.current() - if (!a) return - setModelStore("model", a.name, model) - if (options?.recent) { - setModelStore("recent", recentModels(model, modelStore.recent)) - save() - } + if (a) setModelStore("model", a.name, { ...ALTIMATE_BASE_MODEL }) + setModelStore("recent", migrateLegacyRecentModels(modelStore.recent)) + save() }) + return true + }, + // Opening an old session restores the model that session was recorded with, verbatim. + // Migration is a decision about the DEFAULT model and is owned by the disclosure flow in + // app.tsx; applying it here rewrote historical threads onto the request-logging tier with + // no per-session prompt, and did so even for users who had explicitly declined. + restoreSession(model: ModelRef) { + if (!selectModel(model)) return undefined + return model }, + // altimate_change end toggleFavorite(model: { providerID: string; modelID: string }) { batch(() => { if (!isModelValid(model)) { diff --git a/packages/tui/src/context/onboarding-telemetry.tsx b/packages/tui/src/context/onboarding-telemetry.tsx index 7d667e1c60..e4b60eb25e 100644 --- a/packages/tui/src/context/onboarding-telemetry.tsx +++ b/packages/tui/src/context/onboarding-telemetry.tsx @@ -19,9 +19,9 @@ export type OnboardingTelemetryEvent = | { name: "onboarding_started" } | { name: "model_picker_shown" - /** The picker also opens from /connect, from declining Big Pickle, and from the prompt + /** The picker also opens from /connect, from declining Altimate Base, and from the prompt * gate — without this the event reads as a first-run impression every time. */ - trigger: "first_run" | "connect_command" | "big_pickle_back" | "prompt_gate" + trigger: "first_run" | "connect_command" | "altimate_base_back" | "prompt_gate" } | { name: "provider_selected" @@ -34,8 +34,12 @@ export type OnboardingTelemetryEvent = /** Set when the pick came from the full catalogue, i.e. after `searchAll`. */ via_search?: boolean } - | { name: "big_pickle_confirm_shown"; origin: "welcome" | "model" } - | { name: "big_pickle_choice"; choice: "accept" | "cancel" } + | { name: "altimate_base_confirm_shown"; origin: "welcome" | "model" } + | { name: "altimate_base_choice"; choice: "accept" | "cancel" } + | { + name: "altimate_base_register_result" + result: "success" | "rate_limited" | "unavailable" | "network" | "error" + } | { name: "scan_gate_shown" } | { name: "scan_gate_choice"; choice: "scan" | "skip" | "dismissed" } | { name: "onboarding_completed" } diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index b6cd705b1e..5ef1d5d451 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -74,6 +74,13 @@ function init() { const renderer = useRenderer() const modeStack = useOpencodeModeStack() + // altimate_change start — allow a modal to veto every dialog replacement/close path + let closeGuard: (() => boolean) | undefined + + function canClose() { + return closeGuard?.() ?? true + } + // altimate_change end createEffect(() => { if (store.stack.length === 0) return @@ -99,6 +106,17 @@ function init() { }, 1) } + // altimate_change start — centralize guarded single-dialog close behavior + function closeTop() { + if (!canClose()) return false + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + refocus() + return true + } + // altimate_change end + useBindings(() => ({ enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), bindings: [ @@ -107,13 +125,12 @@ function init() { desc: "Close dialog", group: "Dialog", cmd: () => { + // altimate_change start — preserve selection when the active close guard vetoes Escape + if (!closeTop()) return if (renderer.getSelection()) { renderer.clearSelection() } - const current = store.stack.at(-1) - current?.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - refocus() + // altimate_change end }, }, { @@ -121,13 +138,12 @@ function init() { desc: "Close dialog", group: "Dialog", cmd: () => { + // altimate_change start — preserve selection when the active close guard vetoes Ctrl-C + if (!closeTop()) return if (renderer.getSelection()) { renderer.clearSelection() } - const current = store.stack.at(-1) - current?.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - refocus() + // altimate_change end }, }, ], @@ -135,6 +151,8 @@ function init() { return { clear() { + // altimate_change start — guard and report bulk dialog closure + if (!canClose()) return false for (const item of store.stack) { if (item.onClose) item.onClose() } @@ -143,8 +161,12 @@ function init() { setStore("stack", []) }) refocus() + return true + // altimate_change end }, replace(input: any, onClose?: () => void) { + // altimate_change start — replacement is a close path and must obey the same guard + if (!canClose()) return false if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable focus?.blur() @@ -159,6 +181,8 @@ function init() { onClose, }, ]) + return true + // altimate_change end }, get stack() { return store.stack @@ -169,6 +193,14 @@ function init() { setSize(size: "medium" | "large" | "xlarge") { setStore("size", size) }, + // altimate_change start — install and safely dispose the active close guard + guardClose(guard: () => boolean) { + closeGuard = guard + return () => { + if (closeGuard === guard) closeGuard = undefined + } + }, + // altimate_change end } } diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx new file mode 100644 index 0000000000..15f3522293 --- /dev/null +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -0,0 +1,348 @@ +/** @jsxImportSource @opentui/solid */ +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { expect, test } from "bun:test" +import { onCleanup, onMount } from "solid-js" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" +import type { OnboardingTelemetryEvent } from "../../../src/context/onboarding-telemetry" + +async function waitUntil(predicate: () => boolean, timeout = 2_000) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +async function mountConfirm( + input: { + registration?: + | { ok: true } + | { ok: false; result: "rate_limited" | "unavailable" | "network" | "error"; message: string } + | (() => Promise< + { ok: true } | { ok: false; result: "rate_limited" | "unavailable" | "network" | "error"; message: string } + >) + modelAvailable?: boolean + origin?: "welcome" | "migration" + } = {}, +) { + const [ + { DialogProvider, useDialog }, + { + DialogAltimateBaseConfirm, + ALTIMATE_BASE_DISCLOSURE, + resetSetupComplete, + markFirstRunActive, + useSetupComplete, + }, + { OnboardingTelemetryProvider }, + { ArgsProvider }, + { KVProvider }, + { ThemeProvider }, + { TuiConfigProvider }, + { ToastProvider }, + { SDKProvider }, + { AltimateBaseConsentProvider }, + { ProjectProvider }, + { SyncProvider }, + { LocalProvider }, + { OpencodeKeymapProvider, registerOpencodeKeymap }, + { ExitProvider }, + { RouteProvider }, + ] = await Promise.all([ + import("../../../src/ui/dialog"), + import("../../../src/component/altimate-onboarding"), + import("../../../src/context/onboarding-telemetry"), + import("../../../src/context/args"), + import("../../../src/context/kv"), + import("../../../src/context/theme"), + import("../../../src/config"), + import("../../../src/ui/toast"), + import("../../../src/context/sdk"), + // altimate_change — the registration operation is provided through this dedicated context, + // not through SDKProvider; see context/altimate-base-consent.tsx. + import("../../../src/context/altimate-base-consent"), + import("../../../src/context/project"), + import("../../../src/context/sync"), + import("../../../src/context/local"), + import("../../../src/keymap"), + import("../../../src/context/exit"), + import("../../../src/context/route"), + ]) + + resetSetupComplete() + markFirstRunActive() + const events: OnboardingTelemetryEvent[] = [] + const registrations: true[] = [] + const declines: true[] = [] + let replaceDialog = () => false + const model = { + id: "altimate-base", + providerID: "altimate-free", + name: "Altimate Base", + family: "altimate", + status: "active", + capabilities: {}, + cost: { input: 0, output: 0 }, + limit: { context: 65_536, output: 4_096 }, + } + const provider = { id: "altimate-free", name: "Altimate", models: { "altimate-base": model }, env: [] } + const bigPickle = { + ...model, + id: "big-pickle", + providerID: "opencode", + name: "Big Pickle", + family: "glm", + } + const openCodeProvider = { id: "opencode", name: "Legacy Zen", models: { "big-pickle": bigPickle }, env: [] } + const inner = createFetch((url) => { + if (url.pathname === "/instance/dispose") return json({}) + if (url.pathname === "/config/providers") { + return json({ + providers: input.modelAvailable === false ? [openCodeProvider] : [provider, openCodeProvider], + default: {}, + }) + } + if (url.pathname === "/provider") { + return json({ + all: [provider, openCodeProvider], + default: {}, + connected: input.modelAvailable === false ? ["opencode"] : ["altimate-free", "opencode"], + }) + } + return undefined + }) + const source = createEventSource() + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const resolvedConfig = createTuiResolvedConfig({ leader_timeout: 1_000 }) + const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) + onCleanup(off) + + function OpenConfirm() { + const dialog = useDialog() + replaceDialog = () => dialog.replace(() => Session list replacement) + onMount(() => + dialog.replace(() => ( + declines.push(true)} /> + )), + ) + return null + } + + return ( + + {}}> + + + + + + + + { + registrations.push(true) + return typeof input.registration === "function" + ? input.registration() + : (input.registration ?? { ok: true }) + }} + > + + + + + { + events.push(event) + }} + > + + + + + + + + + + + + + + + + + + + ) + } + + const app = await testRender(() => , { kittyKeyboard: true }) + await app.renderOnce() + await Bun.sleep(50) + await app.renderOnce() + return { + app, + events, + disclosure: ALTIMATE_BASE_DISCLOSURE, + setupComplete: useSetupComplete(), + registrations: () => registrations, + declines: () => declines, + replaceDialog: () => replaceDialog(), + cleanup() { + app.renderer.destroy() + resetSetupComplete() + }, + } +} + +test.serial("Altimate Base shows the privacy disclosure before registration and defaults to No", async () => { + const confirm = await mountConfirm() + try { + const frame = confirm.app.captureCharFrame() + expect(confirm.disclosure).toContain("Requests and responses may be logged") + expect(frame).toContain("Use Altimate Base?") + expect(frame.replace(/\s+/g, " ")).toContain("Requests and responses may be logged and used") + expect(frame).toContain("No — pick something else") + expect(frame).toContain("(default)") + expect(confirm.registrations()).toHaveLength(0) + expect(confirm.events).toEqual([{ name: "altimate_base_confirm_shown", origin: "welcome" }]) + } finally { + confirm.cleanup() + } +}) + +test.serial( + "the Big Pickle migration reuses consent, stays out of first-run telemetry, and routes No to the picker", + async () => { + const confirm = await mountConfirm({ origin: "migration" }) + try { + const frame = confirm.app.captureCharFrame() + expect(frame).toContain("No — pick something else") + expect(frame.replace(/\s+/g, " ")).toContain("Requests and responses may be logged and used") + expect(confirm.events).toEqual([]) + + confirm.app.mockInput.pressKey("n") + await waitUntil(() => confirm.declines().length === 1) + expect(confirm.registrations()).toHaveLength(0) + // altimate_change — "No — pick something else" must actually route somewhere: Big Pickle is + // retired, so declining the migration prompt lands the user in the curated picker instead of + // silently leaving the dialog cleared (the label used to promise a re-pick that never + // happened). + await waitUntil(() => confirm.events.some((event) => event.name === "model_picker_shown")) + expect(confirm.events).toEqual([{ name: "model_picker_shown", trigger: "altimate_base_back" }]) + await confirm.app.renderOnce() + expect(confirm.app.captureCharFrame()).toContain("Altimate LLM Gateway") + } finally { + confirm.cleanup() + } + }, +) + +test.serial("declining Altimate Base makes no registration request, and Big Pickle is not offered as a new pick", async () => { + const confirm = await mountConfirm() + try { + confirm.app.mockInput.pressKey("n") + await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_choice")) + expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel" }) + expect(confirm.registrations()).toHaveLength(0) + confirm.app.mockInput.pressKey("/") + await confirm.app.renderOnce() + // altimate_change — Big Pickle is retired as a NEW selectable option: the full catalog opened + // via search must not offer it, even though the fixture still wires up an "opencode" provider + // (used elsewhere to prove the migration path still recognizes a legacy selection). + expect(confirm.app.captureCharFrame()).not.toContain("Big Pickle") + expect(confirm.registrations()).toHaveLength(0) + } finally { + confirm.cleanup() + } +}) + +test.serial("accepting registers once through the private host operation and completes setup", async () => { + const confirm = await mountConfirm() + try { + confirm.app.mockInput.pressKey("y") + await waitUntil(() => confirm.setupComplete()) + expect(confirm.registrations()).toHaveLength(1) + expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "accept" }) + expect(confirm.events).toContainEqual({ name: "altimate_base_register_result", result: "success" }) + expect(confirm.events.filter((event) => event.name === "altimate_base_choice")).toHaveLength(1) + } finally { + confirm.cleanup() + } +}) + +test.serial("registration without a usable model remains incomplete and visibly recoverable", async () => { + const confirm = await mountConfirm({ modelAvailable: false }) + try { + confirm.app.mockInput.pressKey("y") + await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_register_result")) + await Bun.sleep(50) + await confirm.app.renderOnce() + expect(confirm.setupComplete()).toBe(false) + expect(confirm.app.captureCharFrame()).toContain("ready yet. Try again") + } finally { + confirm.cleanup() + } +}) + +test.serial("rate-limited registration stays recoverable and reports a typed outcome", async () => { + const message = "Too many Altimate Base registrations from this network right now. Try again later." + const confirm = await mountConfirm({ + registration: { ok: false, result: "rate_limited", message }, + }) + try { + confirm.app.mockInput.pressKey("y") + await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_register_result")) + await confirm.app.renderOnce() + expect(confirm.setupComplete()).toBe(false) + expect(confirm.registrations()).toHaveLength(1) + expect(confirm.events).toContainEqual({ name: "altimate_base_register_result", result: "rate_limited" }) + expect(confirm.app.captureCharFrame()).toContain("Too many Altimate Base") + } finally { + confirm.cleanup() + } +}) + +test.serial("dismissal keys and backdrop clicks are ignored while registration is in flight", async () => { + let finish!: (result: { ok: true }) => void + let started!: () => void + const began = new Promise((resolve) => { + started = resolve + }) + const pending = new Promise<{ ok: true }>((resolve) => { + finish = resolve + }) + const confirm = await mountConfirm({ + registration: async () => { + started() + return pending + }, + }) + try { + confirm.app.mockInput.pressKey("y") + await began + expect(confirm.replaceDialog()).toBe(false) + await confirm.app.renderOnce() + expect(confirm.app.captureCharFrame()).not.toContain("Session list replacement") + confirm.app.mockInput.pressKey("escape") + await confirm.app.renderOnce() + expect(confirm.app.captureCharFrame()).toContain("Setting up…") + confirm.app.mockInput.pressKey("c", { ctrl: true }) + await confirm.app.renderOnce() + expect(confirm.app.captureCharFrame()).toContain("Setting up…") + await confirm.app.mockMouse.click(0, 0) + await confirm.app.renderOnce() + expect(confirm.app.captureCharFrame()).toContain("Setting up…") + + finish({ ok: true }) + await waitUntil(() => confirm.setupComplete()) + } finally { + confirm.cleanup() + } +}) diff --git a/packages/tui/test/context/altimate-base-consent.test.tsx b/packages/tui/test/context/altimate-base-consent.test.tsx new file mode 100644 index 0000000000..044f151de4 --- /dev/null +++ b/packages/tui/test/context/altimate-base-consent.test.tsx @@ -0,0 +1,83 @@ +/** @jsxImportSource @opentui/solid */ +// altimate_change start — proves the consent-gated Altimate Base registration operation cannot be +// reached through the PUBLIC SDK context (`@opencode-ai/tui/context/sdk`'s `useSDK()`), only +// through the dedicated `context/altimate-base-consent.tsx` module — which is not listed in +// package.json's `exports` map and so cannot be imported from outside this package. This closes +// the gap where any in-process consumer of `useSDK()`, including a plugin-rendered component, +// could call `sdk.altimateBaseRegistration()` directly and mint a Base install identifier without +// the disclosure dialog ever being shown. +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { SDKProvider, useSDK } from "../../src/context/sdk" +import { AltimateBaseConsentProvider, useAltimateBaseConsent } from "../../src/context/altimate-base-consent" +import { createFetch, eventSource } from "../fixture/tui-sdk" + +test("the registration operation is not reachable through the public SDK context", async () => { + const calls: true[] = [] + const register = async () => { + calls.push(true) + return { ok: true as const } + } + + let sdk: ReturnType | undefined + let consent: ReturnType | undefined + + function Probe() { + sdk = useSDK() + consent = useAltimateBaseConsent() + return null + } + + const app = await testRender( + () => ( + + + + + + ), + { kittyKeyboard: true }, + ) + try { + await app.renderOnce() + + // The public SDK context object carries no such property at all, forged or otherwise — a + // plugin that only imports `@opencode-ai/tui/context/sdk` has no way to reach registration. + expect(sdk).toBeDefined() + expect("altimateBaseRegistration" in (sdk as object)).toBe(false) + expect((sdk as Record)["altimateBaseRegistration"]).toBeUndefined() + + // The dedicated context is how the legitimate consent-accept flow reaches the same operation. + expect(consent).toBe(register) + expect(calls).toHaveLength(0) + await consent?.() + expect(calls).toHaveLength(1) + } finally { + app.renderer.destroy() + } +}) + +test("useAltimateBaseConsent is undefined when no host injected a registration operation", async () => { + let consent: ReturnType | undefined | "not-called" = "not-called" + + function Probe() { + consent = useAltimateBaseConsent() + return null + } + + const app = await testRender( + () => ( + + + + ), + { kittyKeyboard: true }, + ) + try { + await app.renderOnce() + expect(consent).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) +// altimate_change end diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index e2f1e45f75..e3c2bb7cd3 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -1,5 +1,15 @@ import { expect, test } from "bun:test" -import { parseModel, recentModels } from "../../src/context/local" +import { + allowsManagedBaseDefault, + ALTIMATE_BASE_MODEL, + isConfirmedExplicitSelection, + isExistingBigPickleSelection, + LEGACY_BIG_PICKLE_MODEL, + migrateLegacyRecentModels, + parseModel, + recentModels, + shouldMigrateLegacyDefault, +} from "../../src/context/local" test("parses model IDs containing slashes", () => { expect(parseModel("provider/family/model")).toEqual({ @@ -20,3 +30,66 @@ test("moves a model to the front, deduplicates, and limits recents", () => { ...recent.slice(6, 10), ]) }) + +test("distinguishes an existing Big Pickle user from a fresh catalogue fallback", () => { + expect(isExistingBigPickleSelection(LEGACY_BIG_PICKLE_MODEL, [], false)).toBe(false) + expect(isExistingBigPickleSelection(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL], false)).toBe(true) + expect(isExistingBigPickleSelection(LEGACY_BIG_PICKLE_MODEL, [], true)).toBe(true) + expect( + isExistingBigPickleSelection({ providerID: "openai", modelID: "gpt-5" }, [LEGACY_BIG_PICKLE_MODEL], false), + ).toBe(false) +}) + +test("honors project provider allowlists during Big Pickle default migration", () => { + expect(allowsManagedBaseDefault(undefined)).toBe(true) + expect(allowsManagedBaseDefault({})).toBe(true) + expect(allowsManagedBaseDefault({ openai: {} })).toBe(false) + expect(allowsManagedBaseDefault({ "altimate-free": {} })).toBe(false) + + expect(shouldMigrateLegacyDefault(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL], false, {})).toBe(true) + expect( + shouldMigrateLegacyDefault(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL], false, { openai: {} }), + ).toBe(false) + expect(shouldMigrateLegacyDefault(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL], true, {})).toBe(false) +}) + +test("preserves a deliberate re-selection of Big Pickle made through a picker after registration", () => { + // A user who already registered Altimate Base can still open `/model` and pick Big Pickle on + // purpose. That choice lands in the exact same `model`/`recent` fields the retired implicit + // default used, so `isConfirmedExplicitSelection` is the only thing that can tell them apart — + // it must be true here, and `shouldMigrateLegacyDefault` must then refuse to overwrite it. + const explicit = isConfirmedExplicitSelection(LEGACY_BIG_PICKLE_MODEL, LEGACY_BIG_PICKLE_MODEL) + expect(explicit).toBe(true) + expect(shouldMigrateLegacyDefault(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL], explicit, {})).toBe(false) +}) + +test("does not confirm an explicit selection once the current model has moved on", () => { + // The marker only vouches for the CURRENT selection. Once the user picks something else (or an + // older session restores a different model), a stale marker must not immunize whatever is + // current now — including a genuinely implicit Big Pickle default. + expect(isConfirmedExplicitSelection(LEGACY_BIG_PICKLE_MODEL, ALTIMATE_BASE_MODEL)).toBe(false) + expect(isConfirmedExplicitSelection(LEGACY_BIG_PICKLE_MODEL, undefined)).toBe(false) + expect(isConfirmedExplicitSelection(undefined, LEGACY_BIG_PICKLE_MODEL)).toBe(false) + + const notExplicit = isConfirmedExplicitSelection(LEGACY_BIG_PICKLE_MODEL, ALTIMATE_BASE_MODEL) + expect(shouldMigrateLegacyDefault(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL], notExplicit, {})).toBe(true) +}) + +test("replaces Big Pickle recents while preserving every unrelated model and order", () => { + expect( + migrateLegacyRecentModels([ + { providerID: "anthropic", modelID: "claude-sonnet" }, + LEGACY_BIG_PICKLE_MODEL, + { providerID: "openai", modelID: "gpt-5" }, + LEGACY_BIG_PICKLE_MODEL, + ALTIMATE_BASE_MODEL, + null, + "malformed", + { providerID: "missing-model-id" }, + ]), + ).toEqual([ + ALTIMATE_BASE_MODEL, + { providerID: "anthropic", modelID: "claude-sonnet" }, + { providerID: "openai", modelID: "gpt-5" }, + ]) +}) diff --git a/packages/tui/test/util/presentation.test.ts b/packages/tui/test/util/presentation.test.ts index d1aab4ea49..48622ca166 100644 --- a/packages/tui/test/util/presentation.test.ts +++ b/packages/tui/test/util/presentation.test.ts @@ -4,5 +4,6 @@ import { sessionEpilogue } from "../../src/util/presentation" test("formats session continuation summary", () => { const epilogue = sessionEpilogue({ title: "A session", sessionID: "ses_123" }) expect(epilogue).toContain("A session") - expect(epilogue).toContain("opencode -s ses_123") + // altimate_change — the continuation command follows the Altimate CLI branding + expect(epilogue).toContain("altimate -s ses_123") }) diff --git a/research/altimate-base-release-2026-08-30/README.md b/research/altimate-base-release-2026-08-30/README.md new file mode 100644 index 0000000000..5c77ab3c47 --- /dev/null +++ b/research/altimate-base-release-2026-08-30/README.md @@ -0,0 +1,17 @@ +# Altimate Base release — 2026-08-30 + +The release-readiness report, the security review snapshot, and the security fix verification for +this release are **not** kept in this repository. + +They describe gateway deployment topology, secret storage, service-account scoping, rollback +procedure, and incident response for infrastructure that lives outside this repo. This repository +is public, so those documents belong in the internal research vault instead: + + Research/Altimate Base Release 2026-08-30/ + +What is publicly documented about Altimate Base lives where users will actually look for it: + +- `docs/docs/configure/providers.md` — setup, gateway configuration, credential storage +- `docs/docs/reference/security-faq.md` — what is sent, what is logged, what is retained +- `docs/docs/reference/network.md` — outbound destinations +- `docs/docs/reference/telemetry.md` — onboarding events