From 8c0467220550af300a94ffeb70e8e6fb2ceb100b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 09:05:20 +0000 Subject: [PATCH 1/2] test: restructure the suite Phase P-T2 of docs/improvement-plan.md. Test-only: no source file changes and no behaviour changes. - Every socket-bound case is in the mock suite. The nine unit files that booted the mock gateway through testing-kit.ts are merged into test/mock/request-shapes.mock.ts and a new protocol-endpoints.mock.ts table. The two offline e2e files that bind a port (patched-fetch-headers, refusal-classification) moved there too. The device-login routes left the gateway mock. - createMockContext, HarnessSimulator and the rest of testing-kit.ts are gone. Services are built on a real cordis Context, and lifecycle.test.ts mounts the plugin the way the Loader does (test/support/composition.ts). degradedHost remains only for the degraded-composition diagnostics. - 43 cases that tested only the harness or the mock gateway are deleted. 33 duplicated cases are merged and 16 restatements are deleted; the plan names each one. - Hard-coded catalog counts and vendor ids are now invariants, or a row picked by shape, so a vendor catalog refresh no longer breaks the suite by construction. - The seven largest test files are split by subject, the live suites read their environment through test/support/e2e-env.ts, and the duplicated helpers and hard-coded ses_ ids are removed. - src/utils/session-id.ts, src/opencode-store-contract.ts and the e2e environment reader have their own tests, and src/lifecycle.ts, src/stream-hook.ts and src/fetch-patch.ts have per-file coverage floors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- .github/workflows/ci.yml | 10 +- AGENTS.md | 5 +- CONTRIBUTING.md | 30 +- docs/engineering-notes.md | 4 +- docs/improvement-plan.md | 35 +- docs/protocol-routing-and-merge.md | 6 +- docs/redirect-planes.md | 6 +- docs/upstream-findings.md | 2 +- test/auth-api-key.test.ts | 148 --- test/catalog-docs.test.ts | 187 +++ test/catalog-enrichment.test.ts | 397 ++++++ test/catalog-refresh.test.ts | 216 ++++ test/catalog-routing.test.ts | 198 +++ test/catalog.test.ts | 1089 ++--------------- test/client-headers-affinity.test.ts | 218 ---- test/composition.test.ts | 83 +- test/dispatch-levels.test.ts | 176 +++ test/e2e-env.test.ts | 126 ++ test/e2e/dispatch-levels.e2e.ts | 154 +-- test/e2e/host-binary.e2e.ts | 14 +- test/e2e/keyed-probes.e2e.ts | 26 +- test/e2e/keyless-matrix.e2e.ts | 18 +- test/e2e/opencode-live.e2e.ts | 18 +- test/e2e/protocol-routing.e2e.ts | 23 +- test/endpoint-completions.test.ts | 135 -- test/endpoint-google.test.ts | 134 -- test/endpoint-messages.test.ts | 124 -- test/endpoint-models-catalog.test.ts | 99 -- test/endpoint-responses.test.ts | 168 --- test/endpoint-usage-quota.test.ts | 112 -- test/fetch-patch-headers.test.ts | 462 +++++++ test/fetch-patch-observability.test.ts | 264 ++++ test/fetch-patch-tools.test.ts | 375 ++++++ test/fetch-patch.test.ts | 1059 ++-------------- test/free-tier-contract.test.ts | 48 +- test/go-discovery-base-url.test.ts | 143 +++ test/go-discovery-keys.test.ts | 593 +++++++++ test/go-discovery-store.test.ts | 121 ++ test/go-discovery.test.ts | 867 +------------ test/harness-context-remotes.test.ts | 97 -- test/harness-loader-versions.test.ts | 92 -- test/host-characterization.test.ts | 23 +- test/host-discovery.test.ts | 18 +- test/host-editor-contracts.test.ts | 8 +- test/host-harness.test.ts | 217 ---- test/host-planes-config.test.ts | 228 ++++ test/host-planes-credentials.test.ts | 278 +++++ test/host-planes.test.ts | 671 ++-------- test/host-waterfall.test.ts | 26 +- test/keyed-probes.test.ts | 46 + test/lifecycle.test.ts | 1059 ++++++++-------- test/manifest.test.ts | 2 +- test/mock/gateway-contract.mock.ts | 225 ---- test/mock/helpers.ts | 45 +- test/mock/opencode-gateway.ts | 226 +--- .../patched-fetch-headers.mock.ts} | 51 +- test/mock/plane-dispatch.mock.ts | 9 +- test/mock/protocol-endpoints.mock.ts | 293 +++++ .../refusal-classification.mock.ts} | 12 +- test/mock/request-shapes.mock.ts | 416 ++----- test/mock/testing-kit.ts | 608 --------- test/opencode-store-contract.test.ts | 114 ++ test/opencode-store-service.test.ts | 10 +- test/plane-provider.test.ts | 12 +- test/purge-service.test.ts | 16 +- test/reload.test.ts | 18 +- test/response-errors.test.ts | 235 ---- test/route-claim-listener.test.ts | 325 +++++ test/route-claim-reconcile.test.ts | 171 +++ test/route-claim-teardown.test.ts | 191 +++ test/route-claim.test.ts | 766 +----------- test/session-id.test.ts | 212 ++++ test/session.test.ts | 78 +- test/settings-page-card.test.tsx | 352 ++++++ test/settings-page-fields.test.tsx | 122 ++ test/settings-page-remotes.test.tsx | 221 ++++ test/settings-page.test.tsx | 689 +---------- test/stream-hook.test.ts | 174 ++- test/support/card-tree.ts | 27 + test/support/catalog-fixture.ts | 73 +- test/support/composition.ts | 175 +++ test/support/cordis-wait.ts | 87 ++ test/support/e2e-env.ts | 120 ++ test/support/host-planes.ts | 178 +++ test/support/host.ts | 137 +-- test/support/keyed-probes.ts | 13 +- test/support/loader-rows.ts | 93 ++ test/support/offline.ts | 12 +- test/support/route-claim-stand-in.ts | 186 +++ test/support/src.ts | 17 + test/support/usage-fixture.ts | 115 ++ test/test-helpers.ts | 40 +- test/usage-service-credentials.test.ts | 166 +++ test/usage-service-failures.test.ts | 242 ++++ test/usage-service-readings.test.ts | 254 ++++ test/usage-service.test.ts | 733 +---------- test/usage.test.ts | 341 ------ vite.config.ts | 52 +- vitest.e2e.config.ts | 5 +- vitest.mock.config.ts | 2 + 100 files changed, 9233 insertions(+), 10084 deletions(-) delete mode 100644 test/auth-api-key.test.ts create mode 100644 test/catalog-docs.test.ts create mode 100644 test/catalog-enrichment.test.ts create mode 100644 test/catalog-refresh.test.ts create mode 100644 test/catalog-routing.test.ts delete mode 100644 test/client-headers-affinity.test.ts create mode 100644 test/dispatch-levels.test.ts create mode 100644 test/e2e-env.test.ts delete mode 100644 test/endpoint-completions.test.ts delete mode 100644 test/endpoint-google.test.ts delete mode 100644 test/endpoint-messages.test.ts delete mode 100644 test/endpoint-models-catalog.test.ts delete mode 100644 test/endpoint-responses.test.ts delete mode 100644 test/endpoint-usage-quota.test.ts create mode 100644 test/fetch-patch-headers.test.ts create mode 100644 test/fetch-patch-observability.test.ts create mode 100644 test/fetch-patch-tools.test.ts create mode 100644 test/go-discovery-base-url.test.ts create mode 100644 test/go-discovery-keys.test.ts create mode 100644 test/go-discovery-store.test.ts delete mode 100644 test/harness-context-remotes.test.ts delete mode 100644 test/harness-loader-versions.test.ts delete mode 100644 test/host-harness.test.ts create mode 100644 test/host-planes-config.test.ts create mode 100644 test/host-planes-credentials.test.ts delete mode 100644 test/mock/gateway-contract.mock.ts rename test/{e2e/patched-fetch-headers.e2e.ts => mock/patched-fetch-headers.mock.ts} (76%) create mode 100644 test/mock/protocol-endpoints.mock.ts rename test/{e2e/refusal-classification.e2e.ts => mock/refusal-classification.mock.ts} (97%) delete mode 100644 test/mock/testing-kit.ts create mode 100644 test/opencode-store-contract.test.ts delete mode 100644 test/response-errors.test.ts create mode 100644 test/route-claim-listener.test.ts create mode 100644 test/route-claim-reconcile.test.ts create mode 100644 test/route-claim-teardown.test.ts create mode 100644 test/session-id.test.ts create mode 100644 test/settings-page-card.test.tsx create mode 100644 test/settings-page-fields.test.tsx create mode 100644 test/settings-page-remotes.test.tsx create mode 100644 test/support/card-tree.ts create mode 100644 test/support/composition.ts create mode 100644 test/support/cordis-wait.ts create mode 100644 test/support/e2e-env.ts create mode 100644 test/support/host-planes.ts create mode 100644 test/support/loader-rows.ts create mode 100644 test/support/route-claim-stand-in.ts create mode 100644 test/support/usage-fixture.ts create mode 100644 test/usage-service-credentials.test.ts create mode 100644 test/usage-service-failures.test.ts create mode 100644 test/usage-service-readings.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0abc60c5..eceab8e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -277,6 +277,7 @@ jobs: # version in the user agent the probes carry (identity.ts), the # anthropic-version every Messages probe sends # (request-shape.ts), and the probes' own inputs, CI arming + # and the environment reader that arms a key (e2e-env.ts) # included. Left out, and covered by the offline suite and the # nightly `full` run: the generated catalog (catalog-data.ts) # and the live model choice (models-catalog.ts), which the @@ -287,7 +288,7 @@ jobs: # it. grep reads a here-string, not a pipe: under # pipefail, `grep -q` exiting on an early match could kill the # writer with SIGPIPE on a long path list and read as a miss. - if grep -Eq '^(src/host/|src/utils/protocols\.ts$|src/utils/plane-model\.ts$|src/identity\.ts$|src/utils/request-shape\.ts$|test/e2e/keyed-probes\.e2e\.ts$|test/support/keyed-probes\.ts$|test/support/keyless-matrix\.ts$|\.github/workflows/ci\.yml$)' <<<"$changed"; then + if grep -Eq '^(src/host/|src/utils/protocols\.ts$|src/utils/plane-model\.ts$|src/identity\.ts$|src/utils/request-shape\.ts$|test/e2e/keyed-probes\.e2e\.ts$|test/support/keyed-probes\.ts$|test/support/keyless-matrix\.ts$|test/support/e2e-env\.ts$|\.github/workflows/ci\.yml$)' <<<"$changed"; then tier=probes else tier=none @@ -400,8 +401,13 @@ jobs: # models-catalog.ts, config.ts, the generated catalog) is covered on # each pull request by the in-process characterization of the same # S0_TODAY rows, and here by the nightly run. + # The binary e2e's own inputs are in scope too: the file, the rows it + # asserts (support/host.ts), the versions it installs + # (support/host-versions.ts) and the reader its gate and every input + # come through (support/e2e-env.ts). test/keyed-probes.test.ts + # compiles this pattern and checks it. # A here-string, not a pipe: see the e2e job's tier step. - if grep -Eq '^(src/host/|src/lifecycle\.ts$|src/identity\.ts$|src/cordis-context\.ts$|src/models-discovery\.ts$|src/fetch-patch\.ts$|src/stream-hook\.ts$|cordis\.patch\.yml$|test/e2e/host/|test/e2e/host-binary\.e2e\.ts$|test/support/host\.ts$|test/support/host-versions\.ts$|patches/|\.github/workflows/ci\.yml$|vitest\.e2e\.config\.ts$|vite\.config\.ts$)' <<<"$changed"; then + if grep -Eq '^(src/host/|src/lifecycle\.ts$|src/identity\.ts$|src/cordis-context\.ts$|src/models-discovery\.ts$|src/fetch-patch\.ts$|src/stream-hook\.ts$|cordis\.patch\.yml$|test/e2e/host/|test/e2e/host-binary\.e2e\.ts$|test/support/host\.ts$|test/support/host-versions\.ts$|test/support/e2e-env\.ts$|patches/|\.github/workflows/ci\.yml$|vitest\.e2e\.config\.ts$|vite\.config\.ts$)' <<<"$changed"; then echo "run=true" >> "$GITHUB_OUTPUT" else echo "run=false" >> "$GITHUB_OUTPUT" diff --git a/AGENTS.md b/AGENTS.md index 1f8cf14e..c33d0e8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,8 +42,11 @@ Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: - `src/index.ts` · `src/identity.ts` · `src/lifecycle.ts` — the public barrel, the identity constants, and the `apply()` composition (one installer per side effect, in load-bearing order). The barrel is the host's contract and nothing more: `apply`, `Config`, `inject`, `name` and the config types. Everything else is internal, and `test/public-api.test.ts` pins the list, so widening it is a reviewed change. - `test/support/src.ts` — the one door tests use into `src`: each name re-exported from the module that DEFINES it, never through the barrel, so a module move edits one file. `test/architecture.test.ts` enforces both halves — every name is declared in its target, and no test outside the named exemptions (`vi.mock` targets, fresh re-imports after `vi.resetModules()`, the architecture and public-API tests, the jsdom client tests, the `reset-process-state.ts` setup file) imports a `src` path, and no jsdom test loads the door. +- `test/support/composition.ts` — the plugin mounted on a real cordis `Context` the way the Loader mounts it (`mountPlugin`: `ctx.plugin({ apply, Config, inject, name }, config)`, so the schema resolves the row config; a turn through `ctx.waterfall("llm/stream", …)`, teardown through `fiber.dispose()`), for a unit test that means "the plugin, applied". The mount passes the module's exports unchanged, so repeated activations on one root share one cordis runtime, as the Loader's do, and `activated()` is read off the fiber. `degradedHost` is the one stand-in for the plugin's COMPOSITION, for a composition a real `Context` cannot be, because a real one registers what it is asked to and always has `plugin`: (1) a `plugin` that registers nothing, with and without `get`, which drives the three unreachable-service diagnostics; (2) no `plugin`, or neither `plugin` nor `provide`. Its `effect` runs and keeps the disposer, never a no-op. Its two context-only helpers, `until` and `logLines`, come from `test/support/cordis-wait.ts` (which `host.ts` re-exports), so a bare-`Context` file loads no host package. +- `test/support/route-claim-stand-in.ts` — the route claim's own stand-in host, kept on purpose for the fiber states a real host cannot be put in on demand (a LOADING or FAILED pi-ai, a null uid, a refusing registry); it drives `test/route-claim*.test.ts`, and `test/host-planes*.test.ts` covers the claim on the real host. The discovery, Settings-graft and reload tests likewise keep the `llm` object their seam wraps. The client entry's tests (`test/settings-page*.test.tsx`, `test/client-bundle.test.ts`) keep a hand-built settings host of their own: the web runtime has no in-process tier, and the jsdom files must not load the door. The go-discovery and usage-service files serve their loader rows and credentials on `new Context()` (`test/support/loader-rows.ts`), so the reader takes the `ctx.get` arm a real host takes. +- `test/support/e2e-env.ts` — the live suites' environment, read in one place (`readE2eEnvironment`, and `e2e()` for the process's own, read on first call so loading the module reads nothing): whether the live suite runs, the key tier and the key it arms per plane, a mirror's bases, the record directory, the CI run and the host-binary cell. No e2e file reads `process.env` for these itself. `test/e2e-env.test.ts` drives it with environments of its own: the `live` gate, the key each plane arms per tier, blank bases, an unknown tier, the host-binary cell. - `test/support/host-versions.ts` — the host the test install runs (pi, the `@deepseek-ai` cluster, cordis and its loader), named once; `test/host-versions.test.ts` checks the installed tree against it (with `pnpm-lock.yaml` as the record of what pnpm resolved, and the published peer ranges) and `test/harness-channel.test.ts` checks the declarations and the lockfile, including that `@deepseek-ai/schemastery` resolves to one copy inside the cluster's range. -- `test/support/host.ts` — the real host, in-process, in two tiers: **A** (`mountTierA`: a bare cordis `Context`, `LlmRuntime` and `dsh-llm-pi-ai` mounted with `{ providers }`) for the `internal/config` waterfall and pi-ai's credential paths, and **B** (`bootTierB`: dsh-app-boot's `boot()` with a temporary HOME and a test bundle) for the config editor, `settings.describe`, the volatile commit and the characterization of the plugin as it ships. Its fakes (`fakeCredentials`, `fakeLaunchEnvironment`, `recordingFetch`) keep every case offline and its credentials out of the environment (a boot blanks the two keys the plugin reads directly, should the shell export them, and restores them on dispose); `S0_PROFILE` is the contract fixture, `CHARACTERIZED_PROFILE` (S0 plus a declared catalog-unknown id) the characterization's, and `S0_TODAY` the measured per-model rows, tagged per facet, that the binary e2e shares (it reads `S0_TODAY.rows` directly; `todayRowsFor`, which only the characterization calls, answers the same rows for either apply order since the route claim). `logLines` captures every logger line at every level, because cordis's own buffer stops at `info` and tier B prints nothing to the console. Tier A has no loader, so the plugin's default host-module source cannot find pi-ai there; the harness swaps in `hostModulesForTests` through the seam in `src/cordis-context.ts`, and the route claim serves the planes in tier A as it does on the real host; `test/host-harness.test.ts` exercises the builder options no contract or characterization case passes, and tier B's two failure paths (an invalid first row, an exported key). Tier B's rows carry PACKAGE names through a stub module loader, never `cordis:` builtins, because the plugin's default host-module source finds pi-ai's entry by `options.name` (`piAiEntry` in `src/host/host-modules.ts`) and a builtin-named row would leave the route claim without pi-ai's module. It also fixes which of pi-ai and the plugin applies first (`order`), because the loader starts rows in parallel and the published binary applies the plugin first; the held row is released when the first one applies or its fiber settles, so an invalid first row fails instead of hanging the boot. +- `test/support/host.ts` — the real host, in-process, in two tiers: **A** (`mountTierA`: a bare cordis `Context`, `LlmRuntime` and `dsh-llm-pi-ai` mounted with `{ providers }`) for the `internal/config` waterfall and pi-ai's credential paths, and **B** (`bootTierB`: dsh-app-boot's `boot()` with a temporary HOME and a test bundle) for the config editor, `settings.describe`, the volatile commit and the characterization of the plugin as it ships. Its fakes (`fakeCredentials`, `fakeLaunchEnvironment`, `recordingFetch`) keep every case offline and its credentials out of the environment (a boot blanks the two keys the plugin reads directly, should the shell export them, and restores them on dispose); `S0_PROFILE` is the contract fixture, `CHARACTERIZED_PROFILE` (S0 plus a declared catalog-unknown id) the characterization's, and `S0_TODAY` the measured per-model rows, tagged per facet, that the binary e2e shares (it reads `S0_TODAY.rows` directly; `todayRowsFor`, which only the characterization calls, answers the same rows for either apply order since the route claim). `logLines` captures every logger line at every level, because cordis's own buffer stops at `info` and tier B prints nothing to the console. Tier A has no loader, so the plugin's default host-module source cannot find pi-ai there; the harness swaps in `hostModulesForTests` through the seam in `src/cordis-context.ts`, and the route claim serves the planes in tier A as it does on the real host. Tier B's `pluginRow` files the plugin's row under another id and package (the rename notice's case), and its two failure paths (an invalid first row, in each apply order) are `host-planes`' cases. Tier B's rows carry PACKAGE names through a stub module loader, never `cordis:` builtins, because the plugin's default host-module source finds pi-ai's entry by `options.name` (`piAiEntry` in `src/host/host-modules.ts`) and a builtin-named row would leave the route claim without pi-ai's module. It also fixes which of pi-ai and the plugin applies first (`order`), because the loader starts rows in parallel and the published binary applies the plugin first; the held row is released when the first one applies or its fiber settles, so an invalid first row fails instead of hanging the boot. - `test/support/keyless-matrix.ts` — the keyless 401-vs-404 probe matrix's cell table and classifier over three URL shapes (legacy `/zen(/go)/v1`, the root `/inference(/go)/v1`, modern `/inference(/go)/{openai,anthropic,google}/…`), the keyless free-model probe's cells and classifier, and the cells each `endpoint` setting dispatches to (`TODAY_DISPATCH`, `LEGACY_DISPATCH`). Pure, though it lives in `test/support/` rather than `src/utils/`: a function of its arguments, with no code path that can set a credential header; `test/e2e/keyless-matrix.e2e.ts` sends the cells. - `test/support/keyed-probes.ts` — the keyed cells (the keyless ones with the plane's key in the header its protocol reads, and a body of a few tokens), their classifier, and the `OPENCODE_E2E_KEYED` tier every keyed live suite reads its key through (`armedKey`). Pure: it takes the variable's value and the key as arguments; `test/e2e/keyed-probes.e2e.ts` sends the cells. - `test/support/endpoint-record.ts` — the dated markdown table both live probe suites write, which CI publishes and `docs/redirect-planes.md` §13 holds; `test/endpoint-record.test.ts` keeps that section in the formatter's columns and row order. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ffbd22b5..312aefd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ pnpm run catalog:shim # regenerate src/catalog-data.ts from models.dev ## End-to-end suite -`pnpm run test:e2e` runs `test/e2e/**/*.e2e.ts` through its own config (`vitest.e2e.config.ts`), so `pnpm run test` stays a fully offline, deterministic unit run. Nothing here is collected by the unit suite. +`pnpm run test:e2e` runs `test/e2e/**/*.e2e.ts` through its own config (`vitest.e2e.config.ts`), so `pnpm run test` stays a fully offline, deterministic unit run. Nothing here is collected by the unit suite. Every switch these files obey — `OPENCODE_E2E`, the key tier, a mirror's bases, the record directory, the CI run and the host-binary cell — is read in one place, `test/support/e2e-env.ts` (`readE2eEnvironment`, and `e2e()` for the process's own, read on first call); no e2e file reads `process.env` for them itself. ```sh OPENCODE_E2E=1 OPENCODE_API_KEY=… OPENCODE_GO_API_KEY=… pnpm run test:e2e @@ -43,28 +43,37 @@ OPENCODE_E2E=1 OPENCODE_API_KEY=… OPENCODE_GO_API_KEY=… pnpm run test:e2e What the main files are for: - `opencode-live.e2e.ts` — the only place the plugin meets the real API. It catches what a stub cannot: **the vendor changing the payload**. The meter parses `/zen/go/v1/usage` and the picker consumes an enriched `/models` listing, so both shapes are asserted against the live service. The unkeyed cases assert reachability (never a 404) and that our header set does not change the gateway's verdict on a request — compared against the same call without it, rather than hardcoding a status the vendor may tighten. -- `patched-fetch-headers.e2e.ts` — a local `node:http` listener, so the _outgoing_ header set crosses a real socket. The live gateway cannot echo a request back; everywhere else in the suite the injected headers are asserted against a captured `fetch`. - `keyless-matrix.e2e.ts` — the keyless 401-vs-404 matrix: two planes × four protocols × three URL shapes — legacy (`/zen(/go)/v1`), the provider-level root (`/inference(/go)/v1`) and modern (`/inference(/go)/{openai,anthropic,google}/…`) — one PAID model per cell from the live catalog, never a free one, which the gateway serves keyless with a real completion. It **sends no key and reads none**, so the `e2e` job runs it on every pull request, and it retries a cell once on a 429, a 5xx or a transport error before reading it. A 404 means the path is gone, a 401/403 that it exists, a 410 or a 400 "Model is unavailable" that the model is. It asserts that all twenty-four cells have a model and were sent, that every legacy and modern answer is readable (a root answer is recorded whatever it is: nothing dispatches there), and that no cell the plugin dispatches to today with the default endpoint (`auto`, an alias of `modern`; `TODAY_DISPATCH`: all four protocols on the modern family, per plane) is gone. A redirect is never followed (`redirect: "manual"`): it reads as `redirected`, unreadable. Beside the matrix, a keyless free-model probe sends a free completions model to `/inference/openai/v1/chat/completions` and asserts it is served or free-tier-gated; a rate limit (a 429, or a 429 and then no answer) skips it. `endpoint: legacy` is recorded, not guarded: the cells it dispatches to (`LEGACY_DISPATCH`: completions, Responses and Messages on both planes, and Zen's Google; Go's Google stays on `/inference/go/google/v1beta`, `GO_GOOGLE_BASE_URL`) must answer readably, like every legacy cell, but their path-gone is recorded and asserted nowhere live, and `test/endpoint-family.test.ts` pins their mapping offline. Both probe suites rewrite their record after every cell, so a job killed at its time limit keeps what it measured. The cell table, the sets and the classifiers are a pure module (`test/support/keyless-matrix.ts`), unit-tested offline in `test/keyless-matrix.test.ts`, which also derives `TODAY_DISPATCH` from the URLs `S0_TODAY` measured. - `keyed-probes.e2e.ts` — the keyed half: the same twenty-four cells, each sent with the plane's own key in the header its protocol reads, the plugin's identity headers (`PROBE_IDENTITY`: the turn's user agent, `x-opencode-client`, `x-opencode-project`, an in-band session id under both session headers — the Go plane refuses a keyed request without one; the per-request metric id `x-opencode-request` is left out on purpose) and a body of a few output tokens, on the `probes` and `full` tiers. Per plane it reads only the `TODAY_DISPATCH` cells (`planeOutcome`): it fails when one answers path-gone, wrong-protocol or auth-refused (the flip evidence) or nothing readable — `other`, a redirect, a refused session, `transport-error` or `no-model` (not flip evidence); it skips when none of them was served (the account's answer, a rate limit, a busy upstream, a retired model, a parsed but refused body), since that measured nothing. Every other cell is recorded. Headers and keys are never printed, and a body only through a redacting excerpt. The cells, tier, classifier and plane outcome are `test/support/keyed-probes.ts`, unit-tested in `test/keyed-probes.test.ts`, which also runs every keyed cell through the offline gateway mock's judge. - `protocol-routing.e2e.ts` — asks the gateway which endpoint serves each model's wire format, at the URL the plane adapter sends it to (the modern family by default, Go's Google included): keyless for the free classes, keyed (and skipped without a secret, or below the `full` tier) for paid ones. The keyed record of every URL shape per protocol, the legacy family included, is `keyed-probes.e2e.ts`. -- `dispatch-levels.e2e.ts` — every thinking level the picker offers is one pi's own rule accepts, swept over the whole shipped catalog with no network; then a keyed live block, on the `full` tier, sends one toggle model per plane and protocol through the plane adapter on a tier-A host with the picker's "On" effort, and skips without the plane's key, below the `full` tier, or when no toggle model was entitled on the account. -- `refusal-classification.e2e.ts` — the refusal classifier's verdicts on the gateway's bytes over a real `node:http` socket. +- `dispatch-levels.e2e.ts` — a keyed live block, on the `full` tier, that sends one toggle model per plane and protocol through the plane adapter on a tier-A host with the picker's "On" effort, and skips without the plane's key, below the `full` tier, or when no toggle model was entitled on the account. Its offline half — every thinking level the picker offers is one pi's own rule accepts, swept over the whole shipped catalog — needs no network and no port, so it is `test/dispatch-levels.test.ts` in the unit run. - `host-binary.e2e.ts` — the published host binary; see [The real-host harness](#the-real-host-harness). It skips unless `DSH_HOST_E2E=1`. +## Mock suite + +`pnpm run test:mock` runs `test/mock/**/*.mock.ts` through `vitest.mock.config.ts`: every case that binds a socket lives there, and none of them needs a key or leaves the loopback interface, so CI runs it on every push. The gateway mock (`test/mock/opencode-gateway.ts`, served by `gateway-server.ts`) refuses what the vendor refuses, so a 200 means the request the plugin built also satisfies the endpoint's contract; `test/mock/helpers.ts` is the one harness (`gatewayHarness`, `fixtureModel`, `lastRecorded`), and a request the gateway mock judges takes its model from the catalog (`fixtureModel`, or `firstSpecFor` by SDK), never a named vendor id. Two exceptions are deliberate: `plane-dispatch.mock.ts` sends the characterized S0 ids, because they are the S0 fixture, and the model names in the two loopback-listener files (`patched-fetch-headers`, `refusal-classification`) are turn labels nothing judges. A case asserts what the PLUGIN put on the wire: one that would pass with a raw `fetch` carrying the same headers tests the mock, not the plugin, and does not belong here. + +- `request-shapes.mock.ts` — the request shape per configuration: session, origin, project, user-agent, credential and the gateway's refusals passed through untouched. +- `protocol-endpoints.mock.ts` — each protocol's endpoint, streamed and unary, on its own credential header; the usage listing; the models listing enriched or not; the core-tool fallback. +- `plane-dispatch.mock.ts` — every characterized model on each plane, through the real `LlmRuntime` and the plugin's `PlaneAdapter` on a tier-A host. +- `protocol-compat.mock.ts` — plane × protocol × endpoint family, driven through pi's own protocol implementations. +- `patched-fetch-headers.mock.ts` — a local `node:http` listener, so the _outgoing_ header set crosses a real socket. The live gateway cannot echo a request back; everywhere else in the suite the injected headers are asserted against a captured `fetch`. +- `refusal-classification.mock.ts` — the refusal classifier's verdicts on the gateway's bytes over a real `node:http` socket. + ## The real-host harness `test/support/host.ts` builds the host from the packages this repository installs, at the versions `test/support/host-versions.ts` names (pi 1.1.0 with the harness patch, the `@deepseek-ai` cluster at the published host's tag). The routing work is judged against it: the contract tests pin the host facts the design rests on (`docs/improvement-plan.md`, F11–F20), and the characterization rows record what today's plugin does. Two tiers, both in-process: -- **Tier A — `mountTierA`.** A bare cordis `Context`, `LlmRuntime`, and `dsh-llm-pi-ai` mounted as a plugin with `{ providers }` (and optionally this plugin). No loader and no files. Use it for the `internal/config` waterfall and pi-ai's credential paths — anything that needs a fiber and the registry and nothing that reads a profile (`host-waterfall`, `host-shapes`, `host-credentials`). Mounted here (`plugin`), this plugin is the real one: tier A has no loader, so `mountTierA` swaps the plugin's host-module source for `hostModulesForTests` through the seam in `src/cordis-context.ts` (`replaceHostModuleSource`), restores it on dispose, and the route claim serves both planes as it does on the real host (`host-harness`, `host-planes`). pi-ai is mounted first there, so the claim restarts it once. Pass `hostModules: null` to keep the plugin's default source instead: with no loader it cannot find pi-ai's module, so the claim stands down and pi-ai keeps the planes (`host-discovery`'s stood-down case). Characterize the plugin as it ships in tier B, which keeps the default source. +- **Tier A — `mountTierA`.** A bare cordis `Context`, `LlmRuntime`, and `dsh-llm-pi-ai` mounted as a plugin with `{ providers }` (and optionally this plugin). No loader and no files. Use it for the `internal/config` waterfall and pi-ai's credential paths — anything that needs a fiber and the registry and nothing that reads a profile (`host-waterfall`, `host-shapes`, `host-credentials`). Mounted here (`plugin`), this plugin is the real one: tier A has no loader, so `mountTierA` swaps the plugin's host-module source for `hostModulesForTests` through the seam in `src/cordis-context.ts` (`replaceHostModuleSource`), restores it on dispose, and the route claim serves both planes as it does on the real host (`host-planes`). pi-ai is mounted first there, so the claim restarts it once. Pass `hostModules: null` to keep the plugin's default source instead: with no loader it cannot find pi-ai's module, so the claim stands down and pi-ai keeps the planes (`host-discovery`'s stood-down case). Characterize the plugin as it ships in tier B, which keeps the default source. - **Tier B — `bootTierB`.** dsh-app-boot's `boot()` with a temporary HOME, a test bundle and a profile patch, the way the harness's own composition tests boot one. Use it for the config editor, `settings.describe`, the loader's volatile commit and the characterization of the plugin as it ships (`host-editor-contracts`, `host-characterization`, `host-discovery`). Its rows carry **package names**, resolved by a stub module loader, not `cordis:` builtins: the plugin's default host-module source finds pi-ai's entry by `options.name === "@deepseek-ai/dsh-llm-pi-ai"` (`piAiEntry` in `src/host/host-modules.ts`), so a builtin-named row would leave the route claim without pi-ai's module. The loader starts rows in parallel, so tier B makes the order explicit: `order: "plugin-first"` (the default, and what the published binary does) or `"pi-ai-first"`. What every case does: - **Install the recording fetch before mounting** (`vi.stubGlobal("fetch", recordingFetch().fetch)`) and restore it with `vi.unstubAllGlobals()` after disposal: the plugin's fetch patch wraps whatever `fetch` is current when it mounts. The recording fetch answers `opencode.ai` and `models.opencode.ai` with a 401, refuses every other host, and keeps credential headers by length only. - **Dispose in `afterEach`** (or a `finally`), and boot per case: a key the fetch patch captured in one case would change the next one's listing. -- **Credentials come from the fakes, not the environment.** The fakes (`fakeCredentials`, `fakeLaunchEnvironment`) are provided on the root, which is where pi-ai looks first. The plugin reads `OPENCODE_API_KEY`/`OPENCODE_GO_API_KEY` from the environment directly, so `bootTierB` blanks either one your shell exports for the life of the boot and restores it on dispose: the suite measures the keyless deployment whatever the shell holds (`host-harness` pins it). A case never sets them itself. +- **Credentials come from the fakes, not the environment.** The fakes (`fakeCredentials`, `fakeLaunchEnvironment`) are provided on the root, which is where pi-ai looks first. The plugin reads `OPENCODE_API_KEY`/`OPENCODE_GO_API_KEY` from the environment directly, so `bootTierB` blanks either one your shell exports for the life of the boot and restores it on dispose: the suite measures the keyless deployment whatever the shell holds, and the characterization's keyless `[BASELINE]` rows are the guard (they fail on a shell that exports a key if the blanking ever stops). That guard only fires where a key is exported: CI's unit job exports none, so there the blanking has nothing to do and nothing checks it. A case never sets them itself. - **Give tier-B cases 30 s**, and wait with a deadline (`waitForPlaneOwner`, 10 s by default, so a plane the plugin never claims fails by name inside the hook's budget), never a fixed sleep: the route claim registers the planes asynchronously, after pi-ai's commit. `piAiRestarts` counts pi-ai's restarts when a case needs to know how many the claim caused. - **A first row that fails does not hang the boot.** Tier B holds the second of pi-ai and the plugin until the first applies OR its fiber settles in any state, so a row whose config the schema rejects boots with that fiber FAILED (`fiber.await()` rethrows the real error) and the other row applied. @@ -80,7 +89,7 @@ What every case does: - **Cells.** `pinned` forces pi to `HOST_VERSIONS.pi` with npm `overrides` and applies the harness patch with `patch -p1`, then asserts every row. `floating` takes the pi npm resolves for the published host. It skips the per-model rows (a newer pi's own entry can move a model's metadata) and the Settings fetch's order (pi's list first); the listings before and after a turn, the save rows (the catalog-unknown id is accepted on the first try) and the row that the plane provider is built from the host prefix's own pi are asserted in both cells, since the plugin owns both planes and none of them depends on which ids pi describes. Its probe JSON is the record. - **Settings' fetch.** The probe also records what Settings → Models → Fetch offers on each plane, through the gateway's Remote face. Both cells assert the whole servable catalog, each id once, in the Remote's five fields; the pinned cell also asserts the order, pi's own list first, then the catalog models pi lacks. -- **Triggers.** Dispatch and the nightly run take both cells. A pull request takes `pinned`, and only when it touches the host seam — `src/host/**` (the route claim, the plane adapter, the Settings graft, the host-module loaders) and the modules whose behaviour depends on the real loader and apply order beside it, `src/lifecycle.ts`, `src/identity.ts`, `src/cordis-context.ts` and `src/models-discovery.ts`, and the two modules that still install host-level patches until they move under `src/host/` (`src/fetch-patch.ts`, the global fetch wrapper whose order the probe reads, and `src/stream-hook.ts`) (every other row producer, such as the pure plane model and profile in `src/utils/`, `src/key-capture.ts`, `src/models-catalog.ts` or `src/config.ts`, is covered on each pull request by the in-process characterization of the same `S0_TODAY` rows, and by the nightly run) — `cordis.patch.yml`, `test/e2e/host/**`, `test/e2e/host-binary.e2e.ts`, `test/support/host.ts` (the rows the binary e2e asserts), `test/support/host-versions.ts` or `patches/` (what the job installs and patches), `.github/workflows/ci.yml`, `vitest.e2e.config.ts` or `vite.config.ts` (the job's own steps and runner config; `vite.config.ts` also holds the pack config that builds `lib/index.mjs`, the bundle only this job loads into a real host, and the test settings the e2e config inherits) (scoped by a step: a workflow-level path filter would silence every other job; a diff the step cannot compute fails it rather than skipping the job). `package.json` is left out because every dependency bump touches it, so a change to its `test:e2e:host` script alone is exercised only by the nightly run. It needs no secret. +- **Triggers.** Dispatch and the nightly run take both cells. A pull request takes `pinned`, and only when it touches the host seam — `src/host/**` (the route claim, the plane adapter, the Settings graft, the host-module loaders) and the modules whose behaviour depends on the real loader and apply order beside it, `src/lifecycle.ts`, `src/identity.ts`, `src/cordis-context.ts` and `src/models-discovery.ts`, and the two modules that still install host-level patches until they move under `src/host/` (`src/fetch-patch.ts`, the global fetch wrapper whose order the probe reads, and `src/stream-hook.ts`) (every other row producer, such as the pure plane model and profile in `src/utils/`, `src/key-capture.ts`, `src/models-catalog.ts` or `src/config.ts`, is covered on each pull request by the in-process characterization of the same `S0_TODAY` rows, and by the nightly run) — `cordis.patch.yml`, `test/e2e/host/**`, `test/e2e/host-binary.e2e.ts`, `test/support/host.ts` (the rows the binary e2e asserts), `test/support/e2e-env.ts` (the reader the binary e2e's gate and every input come through), `test/support/host-versions.ts` or `patches/` (what the job installs and patches), `.github/workflows/ci.yml`, `vitest.e2e.config.ts` or `vite.config.ts` (the job's own steps and runner config; `vite.config.ts` also holds the pack config that builds `lib/index.mjs`, the bundle only this job loads into a real host, and the test settings the e2e config inherits) (scoped by a step: a workflow-level path filter would silence every other job; a diff the step cannot compute fails it rather than skipping the job). `package.json` is left out because every dependency bump touches it, so a change to its `test:e2e:host` script alone is exercised only by the nightly run. It needs no secret. To run it locally, install the host into a prefix outside the checkout, then point the suite at it (placeholder paths; `pnpm` must be on `PATH`). The first line reads the versions from `test/support/host-versions.ts`, the same way the CI job does, so the snippet never names one itself: @@ -114,12 +123,13 @@ The web profile wires this checkout with `link:`, so builds are picked up like t ## Test conventions - **Deterministic only.** No `Math.random()`, no fixed `sleep()` waits. Async file assertions poll with a deadline (`waitForFileContent`). -- **No secret fixtures.** Session IDs are derived at runtime (`openCodeSessionIdFor`) or read from `OPENCODE_SESSION_ID`; never hardcode `ses_…` or API keys. `lib/` and `*.log` stay gitignored. +- **No secret fixtures.** A session id a gateway, the mock or a live probe READS is derived at runtime (`openCodeSessionIdFor`) or read from `OPENCODE_SESSION_ID`, never written out; an opaque value a captured `fetch` only echoes, or a deliberately malformed input, may stay literal. An API key is never a fixture. `lib/` and `*.log` stay gitignored. - **Restore globals.** Tests that touch `globalThis.fetch` or `process.env` must restore them in `afterEach` (`vi.unstubAllGlobals()`, `delete process.env.…`). - **Offline by construction.** `test/support/offline.ts` refuses every non-loopback request in the unit and mock suites. The catalog's background revalidation used to reach `models.opencode.ai` dozens of times per run, so a networked runner's results could depend on the vendor's live catalog. A case that means to fetch stubs `fetch` itself. - **No order dependence.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test. A new module-level store gets a reset next to its siblings. `pnpm run test:shuffle` is how to check, and CI runs it. - **Tests reach `src` through `test/support/src.ts`.** It re-exports, from each defining module, everything a test needs, so a module move or a barrel change edits one file instead of thirty. A new test imports from `./support/src.ts` (`../support/src.ts` under `test/mock` and `test/e2e`); add a missing name there rather than importing a `src` path. The exceptions are `vi.mock("../src/x.ts")` targets (and their `importOriginal` type), which must name the module; fresh-module re-imports after `vi.resetModules()`, whose subject is module identity; the architecture and public-API tests, whose subject is the `src` layout and the barrel; the jsdom (client) tests, which import the defining module directly, because `src.ts` re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and the next rule keeps that out of client tests; and the setup file `test/support/reset-process-state.ts`, which runs before the jsdom tests too. `test/architecture.test.ts` fails on a static or side-effect `src` import anywhere else, on a dynamic `import("../src/…")` in a file that never calls `vi.resetModules()`, and on a jsdom test that value-imports the door. Importing the door loads the whole `src` graph, host and client, so a module that fails to load fails every test that comes through it. -- **Host-only fixtures stay out of shared helpers.** `test/test-helpers.ts` is imported by the jsdom (client) tests, so anything that needs `node:sqlite` or the filesystem belongs in `test/support/`, as `opencode-store-fixture.ts` does. +- **Host-only fixtures stay out of shared helpers.** `test/test-helpers.ts` is imported by the jsdom (client) tests, so anything that needs `node:sqlite` or the filesystem belongs in `test/support/`, as `opencode-store-fixture.ts` does. It carries no contexts either. +- **A unit test that means "the plugin, applied" mounts it on a real cordis `Context`.** `test/support/composition.ts`'s `mountPlugin` does what the Loader does — `ctx.plugin({ apply, Config, inject, name }, config)`, the module's own exports and the row's config, which makes the schema resolve the config and the fiber carry the plugin's name — so cordis decides when each part runs, a turn goes through `ctx.waterfall("llm/stream", …)` as `LlmRuntime` sends it, and `disposeMounted()` (in `afterEach`) undoes the plugin's effects through `fiber.dispose()`. A service under test gets `new Context()` with what it reads provided on it (one context per service: a cordis Service registers itself on the context it is built on). The mount passes those exports unchanged, so repeated activations on one root share one cordis runtime, as the Loader's do. The go-discovery and usage-service files serve their loader rows and credentials on `new Context()` too (`test/support/loader-rows.ts`). The one stand-in for the plugin's composition, `degradedHost`, is for a composition a real `Context` cannot be, because a real one registers what it is asked to and always has `plugin`: a `plugin` that registers nothing, with and without `get` (the three unreachable-service diagnostics), and a composition with no `plugin`, or with neither `plugin` nor `provide`. Module-level clause tests keep the object their function reads where a real host cannot be put in the state they need on demand: the route claim's stand-in host (`test/support/route-claim-stand-in.ts`: a LOADING or FAILED pi-ai, a null uid, a refusing registry), with `test/host-planes*.test.ts` covering the claim on the real host, and the `llm` objects the discovery, Settings-graft and reload seams wrap. The client entry's tests (`test/settings-page*.test.tsx`, `test/client-bundle.test.ts`) keep a hand-built settings host: the web runtime has no in-process tier, and the jsdom files must not load the door. `until` and `logLines` live in `test/support/cordis-wait.ts` (re-exported by `host.ts`), so a bare-`Context` unit file loads no host package through them. - **A test for a fix fails without it.** Before committing a regression test, revert the fix locally and watch it fail. - **Meaningful coverage.** Every config field in the `SPECS` register (`src/settings-fields.ts`) needs both the on and off path where it has one; every passthrough claim needs a non-OpenCode URL test proving headers are untouched; helper functions exported from `usage-ui.ts` are asserted directly, not re-derived in the test. - **A component with hooks needs a real mount.** Calling `Component(props)` returns an element and runs none of its state, so a hook-bearing component can have twenty passing cases and still be untested — `usage-pill.tsx` sat at 9% exactly that way, with the poll loop, retry and dismissal all uncovered. `test/usage-pill-mount.test.tsx` is the answer: a `// @vitest-environment jsdom` pragma scoped to that one file, so the rest of the suite keeps the fast node environment and the zero-dependency element-tree style. @@ -131,7 +141,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Raise them when you genuinely add coverage** — a PR that lifts statements by a few points should lift the threshold with it, or the next contributor inherits a gate nobody re-measured. - **Never set one above the current measurement.** That does not make the suite better; it makes every subsequent PR fail until someone deletes tests. -`src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. Load-bearing modules carry their own per-file floors, so a well-covered average cannot hide one that stopped being exercised: `host/patch-method.ts` and the four pure plane modules (`utils/protocols.ts`, `utils/model-metadata.ts`, `utils/plane-model.ts`, `utils/plane-profile.ts`) at 100%, because each branch is a reload case, a protocol, a precedence or one of pi-ai's checks; `host/route-claim.ts`, `host/plane-provider.ts` and `host/describe-graft.ts` just below what they measure. `test/architecture.test.ts` fails the build on a floor whose file no longer exists, which would otherwise be a floor on nothing. +`src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. Load-bearing modules carry their own per-file floors, so a well-covered average cannot hide one that stopped being exercised: `host/patch-method.ts` and the four pure plane modules (`utils/protocols.ts`, `utils/model-metadata.ts`, `utils/plane-model.ts`, `utils/plane-profile.ts`) at 100%, because each branch is a reload case, a protocol, a precedence or one of pi-ai's checks; `host/route-claim.ts`, `host/plane-provider.ts` and `host/describe-graft.ts` just below what they measure; and the three modules that install the plugin's effects on the host, `lifecycle.ts`, `stream-hook.ts` and `fetch-patch.ts`, just below what they measure too, with the branches no composition reaches named beside the floor. `test/architecture.test.ts` fails the build on a floor whose file no longer exists, which would otherwise be a floor on nothing. ## Release process (maintainers) diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 9c39a01b..5635351d 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -318,13 +318,13 @@ So the class has one member left standing rather than a pattern, which is the po ## Why one document's counts never drifted, and the others' did -`protocol-routing-and-merge.md` states five counts — 28, 30, 18, 8 and 1 of 85 — and every one is correct, which is not luck. The document says why in its own words: _"a count written into prose does not regenerate with the data it counts"_, and it names the test that holds them. `catalog.test.ts` asserts the table against the shipped catalog, so a count that drifts fails the suite rather than sitting in a file. +`protocol-routing-and-merge.md` states five counts — 28, 30, 18, 8 and 1 of 85 — and every one is correct, which is not luck. The document says why in its own words: _"a count written into prose does not regenerate with the data it counts"_, and it names the test that holds them. `catalog-docs.test.ts` asserts the table against the shipped catalog, so a count that drifts fails the suite rather than sitting in a file. That is the difference between the counts that were wrong this session and this one. The ones that drifted — Go 31, the Zen total in the plane table, the duplicated 12 and 13, the Chinese README's Google line — were all prose that nothing checked. The fix in each case was to correct it; the fix that would have prevented all of them is the one this document already applies, and it is worth copying rather than rediscovering: put the number where a test can reach it. ## The prose totals are still unguarded, and the attempt to guard them is recorded -`catalog.test.ts` already parses and asserts the tables in three documents — the README's routing table, `protocol-routing-and-merge.md`'s "N of M" pairs, and `redirect-planes.md`'s per-SDK matrix. Every count this repository has had to correct lived outside them, in a sentence: the README's total, the Chinese README's copy of it, and the plane table's "85 served / 120 listed". The mechanism that would have caught all three exists and is not pointed at them. +`catalog-docs.test.ts` already parses and asserts the tables in three documents — the README's routing table, `protocol-routing-and-merge.md`'s "N of M" pairs, and `redirect-planes.md`'s per-SDK matrix. Every count this repository has had to correct lived outside them, in a sentence: the README's total, the Chinese README's copy of it, and the plane table's "85 served / 120 listed". The mechanism that would have caught all three exists and is not pointed at them. An attempt to add that case was made and reverted. The regex had to allow for the number being emphasised (`**85** active`), the escaping survived one round of edits and not the second, and the file ended up with no tests running at all. Reverted rather than left half-working, which is this session's rule and the reason the tree is green. diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 7efda867..223b2871 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -28,7 +28,7 @@ Phases land as **stacked pull requests**. Each one is based on the previous phas | P-R1 | `feat:` the plugin owns the plane routes | in review | | P-R2 | `test:` the endpoint families, measured | in review | | P3 | `feat:` Settings complements pi's list | in review | -| P-T2 | `test:` suite structure | planned | +| P-T2 | `test:` suite structure | in review | | P-T3 | `ci:` CI and scripts | planned | | P5 | `refactor:` installers | planned | | P6 | `refactor:` the fetch patch's credential fill | planned | @@ -366,7 +366,7 @@ What stays private is pinned by contract tests against the real host packages: **What remains:** the Models store's join. It lives in the client package `ui-settings-models`, which is not installed here and has no server-side seam to restate, so P-R1 owns it (its contract list below, unconditionally), measured on the binary or with the package added at the pinned cluster tag. - The tier-A F11 smoke (`listProviders()` equals pi-ai's resolved keys) lives in `host-waterfall`, beside the other F-numbered contracts; `host-harness` holds the builder-seam cases. + The tier-A F11 smoke (`listProviders()` equals pi-ai's resolved keys) lives in `host-waterfall`, beside the other F-numbered contracts; `host-harness` held the builder-seam cases until P-T2 retired them as harness self-tests. **Corrections the harness measured** (P-R1 must design for these): - **F8/F12, the save check.** An outermost listener that strips the planes and returns a copy does NOT shield pi-ai's own listener: pi-ai validates the value `next()` returns, which is the raw value the inner chain saw (`llm-pi-ai/src/index.ts:177-186`). Against its stripped previous config every plane entry reads as changed, so the strict check runs on the planes: an editor save is refused while a plane stores any gap id. The claim listener must either keep pi-ai's listener from seeing the planes (run the nested waterfall on the copy, with a re-entrancy guard) or veto the chain for pi-ai's fiber and validate in its place. @@ -406,19 +406,22 @@ What stays private is pinned by contract tests against the real host packages: - the `discoverModels` wrapper (`src/models-discovery.ts`, through `host/patch-method.ts`): on a plane the plugin serves, pi's rows first in pi's order (retired ids dropped, missing fields filled from the catalog), then every servable, non-deprecated catalog model pi lacks (`complementDiscoveredModels`), each id once and whitelisted to `LlmDiscoveredModel`'s five fields; `NO_DISCOVERY` from pi-ai's namespace answers the catalog alone (F21); - `installModelDiscovery`, `toDiscovered`, the `registerModelDiscovery` namespaces and their lifecycle tests deleted; - the "adds nothing" tests and docs rewritten; `test/host-discovery.test.ts` carries the real-host and contract rows, and the binary e2e the Settings fetch row. Refinements: the `RouteClaim.claiming()` seam, so gap rows are offered only while the claim is live and only on the served row's namespace; the whitelist omits a field nobody supplied, unknown modalities included (absent means unknown in the Remote's schema), rather than defaulting it, and narrows a supplied modality list to text/image, omitting the field when nothing survives (as the composer's `catalogInput` does); and `verify-catalog` check 3 retargeted from `mergeDiscoveredModels` to `planeProfileFor`, the composer's own source. -10. **P-T2 `test:` suite structure.** - - Move the mock-booting unit files into `test/mock/`. - - Retire the ad-hoc contexts, `createMockContext` and `HarnessSimulator`. - - Turn constants into invariants, and split the seven big files. - - One shared e2e environment helper. - - Cover `session-id` and `opencode-store-contract`. +10. **P-T2 `test:` suite structure.** In review. Landed as: + - **The mock suite holds every socket-bound case.** The nine unit files that booted the mock gateway through `testing-kit.ts` are gone: their protocol, credential and pass-through cases merged into `test/mock/request-shapes.mock.ts` and the new `protocol-endpoints.mock.ts` (one `it.for(PROTOCOLS)` table, models from the catalog through `fixtureModel`), and `testing-kit.ts` with them (`HarnessSimulator`, `ClientSimulator`, `GatewayTestRunner`, and an `AuthSimulator` that copied `opencode-store-fixture.ts`). The two port-binding offline e2e files (`patched-fetch-headers`, `refusal-classification`) moved into the mock suite, ungated. The device-login routes left the gateway mock: this build has no login. + - **Deleted as self-tests, 43:** `harness-context-remotes` (4) and `harness-loader-versions` (4), which tested `HarnessSimulator`; `gateway-contract.mock.ts` (10) and request-shapes' two mock-only describes (7), raw fetches with no plugin; five of `host-harness`' seven (its two failure-path cases moved to `host-planes`); and thirteen cases of the moved files whose assertions read only the mock's verdict. + - **Merged, 33:** 21 cases of the nine files and four of request-shapes' own (two credential headers, two pass-throughs) became twelve rows — the protocol table's four, its placeholder case and two usage rows, and a five-row pass-through `it.each` — and eight more (seven header cases and one core-tool case) folded into request-shapes' existing cases. **Deleted as restatements, 16:** ten `usage.test.ts` cases of `go-discovery`'s; four `usage.test.ts` `GoUsageService` cases of `usage-service-readings`' and `-failures`' (the two-session spend split, the missing key, the healthy read, and the 429 that is one row of the transient-status table); `free-tier-contract`'s big-pickle case, which read a catalog flag that `catalog.test.ts` asserts for every Zen row and drove no injection; and lifecycle's usage-registration case of composition's. Five cases moved whole (two listing and two core-tool cases to `protocol-endpoints`, the session recovery to request-shapes). + - **Real contexts.** `createMockContext` is gone: services are built on `new Context()` with what they read provided, and `lifecycle.test.ts` mounts the plugin on a real `Context` the way the Loader does (`test/support/composition.ts`, `mountPlugin`: the module's `apply`, `Config`, `inject` and `name`, with the row config), drives `ctx.waterfall("llm/stream", …)` and disposes through `fiber.dispose()`, reading after every case — before any stub is undone — whether a fetch patch is left, and asserting it once the cleanup has run, so a leak is blamed on the case that leaked it; the mount passes the exports unchanged, so repeated activations share one cordis runtime as the Loader's do; the one case that leaves one by construction (two overlapping activations disposed earliest-first) says so as `KNOWN_DEFECT P6`, for P6 to flip. Only the degraded-composition diagnostics keep a stand-in for the plugin's composition (`degradedHost`), because a real `Context` registers what it is asked to and always has `plugin`: a `plugin` that registers nothing, with and without `get` (the three unreachable-service diagnostics), and a composition with no `plugin`, or with neither `plugin` nor `provide`. The go-discovery and usage-service files serve their loader rows and credentials on `new Context()` (`test/support/loader-rows.ts`), so the reader takes the `ctx.get` arm a real host takes. Named exceptions, for module-level clause tests rather than the composition: the route claim keeps its stand-in host (`test/support/route-claim-stand-in.ts`) for fiber states a real host cannot be put in on demand (a LOADING or FAILED pi-ai, a null uid, a refusing registry), with `test/host-planes*.test.ts` covering the claim on the real host; the discovery, Settings-graft and reload tests keep the `llm` object their seam wraps; the client entry's tests (`test/settings-page*.test.tsx`, `test/client-bundle.test.ts`) keep a hand-built settings host, because the web runtime has no in-process tier and the jsdom files must not load the door. + - **Invariants, not counts:** the catalog tests assert non-empty, unique ids, the generator's `is_free` rule, a paid row per served protocol, and Go-only/Zen-only ids from set differences. A case that needs a catalogued or limits row picks it by shape (`findCatalogRow`, `freeZenModel`, the first priced row of the shim or of `GO_MODEL_LIMITS`) and reads its expected value off that row, or seeds the plane-divergent probes (`seedDivergentCatalog`), so a vendor refresh can retire any one id without failing a case about something else. + - **The seven largest files split by subject** (settings-page, fetch-patch, go-discovery, catalog, route-claim, usage-service, host-planes), the first part keeping the original name; shared fixtures in `test/support/`. + - **One e2e environment reader**, `test/support/e2e-env.ts`. `dispatch-levels`' offline sweep is a unit file (`test/dispatch-levels.test.ts`), not a mock one: it binds no port. Every `ses_` id a gateway, the mock or a live probe reads is derived (`openCodeSessionIdFor`), including `protocol-routing`'s, which was 23 characters and refused by the gateway on every keyed case; malformed-id inputs and opaque turn values a captured fetch only echoes stay literal on purpose. + - **Coverage:** `session-id`, `opencode-store-contract` and `e2e-env` have their own files; `lifecycle.ts`, `stream-hook.ts` and `fetch-patch.ts` carry per-file floors with the unreachable branches named. 11. **P-T3 `ci:` CI and scripts.** - **The test run:** one unit run; build before the tests that read `lib/`; type-check `test/**/*.tsx`. - **Setup:** one composite setup action. - **`check.ts`** keeps the lib freshness check, the consumer install and the size tripwire. - **Scripts:** the generators share helpers, and the `verify-*` scripts exit non-zero. - **`no-local-paths`** also covers `.github/` and `*.sh`. -12. **P5 `refactor:` installers.** `installRemoteService` replaces the three mount-once copies. +12. **P5 `refactor:` installers.** `installRemoteService` replaces the three mount-once copies. `mountByProvide` cannot succeed on any composition today: it constructs the service first, the services need `ctx.reflect`, and only a real `Context` (which always has `plugin`) carries it. `installRemoteService` drops the provide arm or makes it publish; `lifecycle.test.ts` pins the dead arm as `KNOWN_DEFECT P5`. 13. **P6 `refactor:` the fetch patch's credential fill.** The adapter path is already D20. - One resolver, for an empty or placeholder wire key only. - A parity test over the deciders, with the adapter path as its own row: a rotated key takes effect, and a service failure propagates. @@ -575,6 +578,18 @@ P0 → P1a → P1b → P2 → P-T1a → P-T1b → P-H → P-R1 → P-R2 → P3 - **Contract:** the wrapper is seen through the gateway and survives a restart. - **Binary e2e:** the Settings fetch action. +**P-T2** + +- **Unit:** + - `session-id`, `opencode-store-contract` and `e2e-env` (the reader driven with environments of its own); + - `dispatch-levels`' offline sweep; + - the per-file floor pins for `lifecycle.ts`, `stream-hook.ts` and `fetch-patch.ts`; + - the CI-scope pins in `keyed-probes.test.ts`. +- **Mock:** + - `protocol-endpoints`' `it.for(PROTOCOLS)` table; + - request-shapes' merged rows; + - the two moved listener files (`patched-fetch-headers`, `refusal-classification`). + **P6** - **Unit:** apply A, apply B, dispose A, dispose B ends at the native `fetch`, in both orders (red on the base). @@ -583,7 +598,7 @@ P0 → P1a → P1b → P2 → P-T1a → P-T1b → P-H → P-R1 → P-R2 → P3 ### The `e2e-host` job (`ci.yml`) -**When it runs:** on dispatch, nightly, and on PRs that touch the host seam — `src/host/**` and, beside it, `src/lifecycle.ts`, `src/identity.ts`, `src/cordis-context.ts` and `src/models-discovery.ts`, whose behaviour depends on the real loader and apply order, and the two modules that still install host-level patches until P6/P10a move them under `src/host/` (`src/fetch-patch.ts`, the global fetch wrapper whose order the probe reads, and `src/stream-hook.ts`) (every other row producer, such as the pure plane model and profile in `src/utils/`, `src/key-capture.ts`, `src/models-catalog.ts` or `src/config.ts`, is covered on each PR by the in-process characterization of the same `S0_TODAY` rows, and by the nightly run) — `cordis.patch.yml`, `test/e2e/host/**`, `test/e2e/host-binary.e2e.ts`, `test/support/host.ts`, `test/support/host-versions.ts`, `patches/**`, `.github/workflows/ci.yml`, `vitest.e2e.config.ts` or `vite.config.ts` (the pack config that builds `lib/index.mjs`, which only this job loads into a real host, and the test settings the e2e config inherits) (scoped by a step, since a workflow-level path filter would silence every job). +**When it runs:** on dispatch, nightly, and on PRs that touch the host seam — `src/host/**` and, beside it, `src/lifecycle.ts`, `src/identity.ts`, `src/cordis-context.ts` and `src/models-discovery.ts`, whose behaviour depends on the real loader and apply order, and the two modules that still install host-level patches until P6/P10a move them under `src/host/` (`src/fetch-patch.ts`, the global fetch wrapper whose order the probe reads, and `src/stream-hook.ts`) (every other row producer, such as the pure plane model and profile in `src/utils/`, `src/key-capture.ts`, `src/models-catalog.ts` or `src/config.ts`, is covered on each PR by the in-process characterization of the same `S0_TODAY` rows, and by the nightly run) — `cordis.patch.yml`, `test/e2e/host/**`, `test/e2e/host-binary.e2e.ts`, `test/support/host.ts`, `test/support/e2e-env.ts` (the reader the binary e2e's gate and inputs come through), `test/support/host-versions.ts`, `patches/**`, `.github/workflows/ci.yml`, `vitest.e2e.config.ts` or `vite.config.ts` (the pack config that builds `lib/index.mjs`, which only this job loads into a real host, and the test settings the e2e config inherits) (scoped by a step, since a workflow-level path filter would silence every job). **What it does:** diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index e373da00..4474c4be 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -14,7 +14,7 @@ OpenCode serves its models on **four different API shapes**, and which one a mod | Google **Generative AI** | `POST /inference/google/v1beta/models/:streamGenerateContent` | 8 of 82 | `@ai-sdk/google` | | OpenAI **Chat Completions** (as pi files it) | `POST /inference/openai/v1/chat/completions` | 1 of 82 | `@ai-sdk/mistral` | -Counts are the shipped catalog. They are asserted against this table by `catalog.test.ts`, because a count written into prose does not regenerate with the data it counts. Go's endpoints are the same paths under `/inference/go/…`; `endpoint: legacy` sends every protocol to `/zen/v1` (`/zen/go/v1` on Go), except Go's Google, which only the inference family serves. +Counts are the shipped catalog. They are asserted against this table by `catalog-docs.test.ts`, because a count written into prose does not regenerate with the data it counts. Go's endpoints are the same paths under `/inference/go/…`; `endpoint: legacy` sends every protocol to `/zen/v1` (`/zen/go/v1` on Go), except Go's Google, which only the inference family serves. **"OpenAI" is two of the four served shapes.** Chat Completions and Responses are different wire formats, and a model that needs Responses does not work on the completions endpoint. The gateway does not fall back; it answers `500`. So each model has to be sent to the endpoint its own SDK names, without asking the user to configure anything. @@ -86,7 +86,7 @@ They are different checks, and none stands in for another: - The **live suite** asks the gateway which endpoint recognises each shipped model, with a hand-written body per protocol. It proves the endpoint answers and what the gateway rejects. - The **mock-gateway suite** runs `llm.stream` through the real `LlmRuntime` and the plane adapter, with the plugin's fetch patch, against a local gateway that refuses a request whose shape its endpoint would not accept. It proves the request the plugin builds is one the endpoint accepts, for every characterized model on both planes and both families. -- The **real-host suites** (`test/host-characterization.test.ts` and `test/host-planes.test.ts`) run the plugin beside `llm-pi-ai` on the real host, in both apply orders, and pin ownership, restarts, the Settings flow, credentials and isolation. `test/host-discovery.test.ts` covers Settings' fetch on tier B in the published binary's order (`plugin-first`), an explicit pi-ai restart that exercises the `NO_DISCOVERY` window a `pi-ai-first` mount would cause, and a stood-down claim on tier A, which mounts pi-ai first; with the claim stood down no restart follows, so the order is immaterial there. +- The **real-host suites** (`test/host-characterization.test.ts` and the `test/host-planes*.test.ts` files) run the plugin beside `llm-pi-ai` on the real host, in both apply orders, and pin ownership, restarts, the Settings flow, credentials and isolation. `test/host-discovery.test.ts` covers Settings' fetch on tier B in the published binary's order (`plugin-first`), an explicit pi-ai restart that exercises the `NO_DISCOVERY` window a `pi-ai-first` mount would cause, and a stood-down claim on tier A, which mounts pi-ai first; with the claim stood down no restart follows, so the order is immaterial there. ### How we support API endpoints in different formats @@ -103,7 +103,7 @@ Two guards keep the invisible half honest, because **a mechanism that silently d | Guard | What it catches | | :-- | :-- | -| `catalog.test.ts` | A generated catalog that names an SDK `PROTOCOL_FOR_SDK` does not map, or a catalog model that resolves off its own plane. It fails and names the model. | +| `catalog-routing.test.ts` | A generated catalog that names an SDK `PROTOCOL_FOR_SDK` does not map, or a catalog model that resolves off its own plane. It fails and names the model. | | `protocol-routing.e2e.ts` | A protocol called servable without a live probe: a path and an auth convention it really sends. | ## 3. The catalog merge diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 5f2b810d..add5e9a1 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -66,8 +66,8 @@ These are the rules a correct implementation satisfies. Each one has a test in | Guard | Holds | Fails when | | :-- | :-- | :-- | -| `catalog.test.ts` · SDK coverage | I5 | the catalog names an SDK `PROTOCOL_FOR_SDK` does not map | -| `catalog.test.ts` · plane × SDK coverage | I1, I2 | a catalog model resolves to a protocol other than its SDK's, or to a base off its own plane | +| `catalog-routing.test.ts` · SDK coverage | I5 | the catalog names an SDK `PROTOCOL_FOR_SDK` does not map | +| `catalog-routing.test.ts` · plane × SDK coverage | I1, I2 | a catalog model resolves to a protocol other than its SDK's, or to a base off its own plane | | `plane-model.test.ts` · protocol and base tables | I2, I3, I4 | a protocol, a family or a route-level base resolves to the wrong endpoint for its plane | | `protocol-routing.e2e.ts` · live probe | I1, I3 | a protocol is declared routable without a path and an auth convention that were really sent, on either plane | | `test/mock/*.mock.ts` · offline gateway | §9's shape table | a plane × protocol × family sends a path, query or wire header its endpoint would refuse, or the mock answers a request the vendor would refuse | @@ -192,7 +192,7 @@ What carried over from the bridge, and why: - **The route names stay the vendor's.** No `google` route is taken; there are no hidden routes at all. - **A Go Gemini turn's replay state carries `provider: "opencode-go"`**, the label it is replayed under, so a signed tool call replays natively and the translators that bridged two labels (`utils/google-replay.ts`) are gone — decided by a mock-gateway replay test (`test/mock/plane-dispatch.mock.ts`). -What is verified, and in what composition: the in-process characterization (`test/host-characterization.test.ts`) and the real-host suite (`test/host-planes.test.ts`) run the plugin on the real `llm` service and `llm-pi-ai` in both apply orders, and the binary e2e (`test/e2e/host-binary.e2e.ts`) runs the built bundle on a published host, asserting that both planes are the plugin's `PlaneAdapter` and that a Go Gemini turn is sent to `https://opencode.ai/inference/go/google/v1beta`. +What is verified, and in what composition: the in-process characterization (`test/host-characterization.test.ts`) and the real-host suite (the `test/host-planes*.test.ts` files) run the plugin on the real `llm` service and `llm-pi-ai` in both apply orders, and the binary e2e (`test/e2e/host-binary.e2e.ts`) runs the built bundle on a published host, asserting that both planes are the plugin's `PlaneAdapter` and that a Go Gemini turn is sent to `https://opencode.ai/inference/go/google/v1beta`. ## 13. Measured: which endpoint families the gateway serves diff --git a/docs/upstream-findings.md b/docs/upstream-findings.md index 3bcac32d..17f5d5c9 100644 --- a/docs/upstream-findings.md +++ b/docs/upstream-findings.md @@ -383,7 +383,7 @@ opencode : 86 -> 86 + muse-spark-1.2, muse-spark-1.2-contributor-free, exo-f - ll-alpha-free, claude-sonnet-4, step-5-preview-free, muse-spark-1.3-contributor-free ``` -Verified after: **0 offered models are measured dead**, and the generator reports `shim is current`. The SDK split moved with it — Zen is now 31 `@ai-sdk/openai` / 17 `@ai-sdk/anthropic` / 29 no-SDK / 1 Mistral / 8 Google — and `README.md`, `docs/redirect-planes.md` and `docs/protocol-routing-and-merge.md` were updated to match, because `catalog.test.ts` holds all three tables to the catalog. +Verified after: **0 offered models are measured dead**, and the generator reports `shim is current`. The SDK split moved with it — Zen is now 31 `@ai-sdk/openai` / 17 `@ai-sdk/anthropic` / 29 no-SDK / 1 Mistral / 8 Google — and `README.md`, `docs/redirect-planes.md` and `docs/protocol-routing-and-merge.md` were updated to match, because `catalog-docs.test.ts` holds all three tables to the catalog. Fixture ids that pointed at now-dead models were retargeted to served ones: `step-5-preview-free` → `space-bunny-free` (or `cat-jam-free` where both planes are needed), `muse-spark-1.3-contributor-free` → `muse-spark-1.2-contributor-free` (still `@ai-sdk/openai`, still free, still answers `403`). diff --git a/test/auth-api-key.test.ts b/test/auth-api-key.test.ts deleted file mode 100644 index 05b2db5a..00000000 --- a/test/auth-api-key.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Test: API Key Authentication Method. - * - * Validates API Key authentication across: - * - Configured API keys (Zen & Go) - * - Environment variables (OPENCODE_API_KEY, OPENCODE_GO_API_KEY) - * - Wire header routing (Authorization: Bearer, x-api-key, x-goog-api-key) - * - Placeholder key rejection (none, null, undefined, unused) - * - * @module test/auth-api-key.test - */ - -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import type { GatewayErrorPayload } from "./mock/protocols/index.ts"; -import { - GatewayTestRunner, - GO_KEY, - MOCK_SESSION, - MOCK_TEXT, - ZEN_KEY, -} from "./mock/testing-kit.ts"; -import { clearCapturedApiKeys } from "./support/src.ts"; - -let runner: GatewayTestRunner; - -beforeAll(async () => { - runner = new GatewayTestRunner(); - await runner.start(); -}); - -afterAll(async () => { - await runner.close(); -}); - -beforeEach(() => { - runner.clearRequests(); - clearCapturedApiKeys(); - vi.stubEnv("OPENCODE_API_KEY", ZEN_KEY); - vi.stubEnv("OPENCODE_GO_API_KEY", GO_KEY); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -describe("API Key Authentication", () => { - it("authenticates OpenAI Responses requests using Authorization Bearer header", async () => { - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - }); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("text/event-stream"); - expect(result.text).toContain(MOCK_TEXT); - - const req = result.request!; - expect(req.plane).toBe("opencode"); - expect(req.kind).toBe("responses"); - expect(req.protocol).toBe("openai-responses"); - expect(req.headers.authorization).toBe(`Bearer ${ZEN_KEY}`); - expect(req.headers["x-opencode-session-id"]).toBe(MOCK_SESSION); - expect(req.reject).toBeNull(); - }); - - it("authenticates Chat Completions requests using Authorization Bearer header", async () => { - const result = await runner.execute( - "opencode", - "/zen/v1/chat/completions", - { - body: { - messages: [{ content: "hi", role: "user" }], - model: "beam-free", - stream: true, - }, - } - ); - - expect(result.status).toBe(200); - expect(result.request?.headers.authorization).toBe(`Bearer ${ZEN_KEY}`); - }); - - it("authenticates Anthropic Messages with x-api-key ALONE", async () => { - const result = await runner.execute("opencode", "/zen/v1/messages", { - body: { - max_tokens: 64, - messages: [{ content: "hi", role: "user" }], - model: "claude-sonnet-4-6", - stream: true, - }, - }); - - expect(result.status).toBe(200); - expect(result.request?.headers["x-api-key"]).toBe(ZEN_KEY); - // **Only the protocol's own header.** The official client passes the key as - // an SDK option and lets the SDK choose the header - // (`provider/provider.ts:240`); it never sets `Authorization` on an Anthropic - // request. A second copy of the credential on a header this protocol does not - // read is not a fallback, it is a divergence. - expect(result.request?.headers.authorization).toBeUndefined(); - }); - - it("authenticates Google Generative AI requests using x-goog-api-key header", async () => { - const result = await runner.execute( - "opencode", - "/zen/v1/models/gemini-3.1-pro:streamGenerateContent?alt=sse", - { - body: { contents: [{ parts: [{ text: "hi" }], role: "user" }] }, - } - ); - - expect(result.status).toBe(200); - expect(result.request?.headers["x-goog-api-key"]).toBe(ZEN_KEY); - // Same rule as Anthropic: the Google protocol reads its own header. - expect(result.request?.headers.authorization).toBeUndefined(); - }); - - it("authenticates Go plane requests using OPENCODE_GO_API_KEY", async () => { - const result = await runner.execute("opencode-go", "/zen/go/v1/usage", {}); - - expect(result.status).toBe(200); - const req = result.request!; - expect(req.plane).toBe("opencode-go"); - expect(req.headers.authorization).toBe(`Bearer ${GO_KEY}`); - }); - - it("rejects placeholder credentials with 401 Invalid API key", async () => { - vi.stubEnv("OPENCODE_API_KEY", "unused"); - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - }); - - expect(result.status).toBe(401); - const body = result.json(); - expect(body.error.type).toBe("invalid_api_key"); - expect(result.request?.reject).toBe("credential"); - }); -}); diff --git a/test/catalog-docs.test.ts b/test/catalog-docs.test.ts new file mode 100644 index 00000000..1dfaa32b --- /dev/null +++ b/test/catalog-docs.test.ts @@ -0,0 +1,187 @@ +/** + * OpenCode Model Catalog: the README's routing table, against the catalog. + * + * @module test/catalog-docs.test + */ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG } from "./support/src.ts"; + +/** + * The README's routing table, asserted against the catalog it describes. + * + * A count written into prose does not regenerate with the data it counts, and + * this file's history is the evidence: the table said 28 models named no SDK and + * had no row for mistral at all, a regeneration after it was written. Nothing + * failed, because nothing was checking - and a user reading the table would have + * been told the wrong thing about the catalog they were choosing from. + * + * So the table is parsed and compared. When the catalog is regenerated with + * different counts, this goes red and says which numbers to update, instead of + * the prose drifting quietly out of date. + */ +describe("catalog · the README's routing table", () => { + const readme = () => + readFile(new URL("../README.md", import.meta.url), "utf8"); + + /** `| \`@ai-sdk/x\` | 12 | ... |` and `| _(absent)_ | 12 | ... |`, in order. */ + const documentedCounts = (text: string): Map => { + const counts = new Map(); + for (const line of text.split("\n")) { + const match = /^\|\s*(?:`([^`]+)`|_\(absent\)_)\s*\|\s*(\d+)\s*\|/u.exec( + line + ); + if (match !== null) { + counts.set(match[1] ?? "(absent)", Number(match[2])); + } + } + return counts; + }; + + it("matches the catalog it counts", async () => { + const measured = new Map(); + for (const model of Object.values(OPENCODE_ZEN_CATALOG)) { + const sdk = model.provider_npm ?? "(absent)"; + measured.set(sdk, (measured.get(sdk) ?? 0) + 1); + } + + // The table has to describe every SDK the catalog names - a row that is + // missing is the failure the mistral row was. + expect(documentedCounts(await readme())).toEqual(measured); + }); + + it("holds the protocol doc's table to the same catalog", async () => { + // The same class of claim in a second document: "28 of 84" is a count + // written into prose, and it drifts exactly the way the README's did. The + // numbers are parsed rather than trusted, and the total has to match too. + const text = await readFile( + new URL("../docs/protocol-routing-and-merge.md", import.meta.url), + "utf8" + ); + const pairs = [...text.matchAll(/(\d+) of (\d+)/gu)].map((m) => [ + Number(m[1] ?? 0), + Number(m[2] ?? 0), + ]); + const total = Object.keys(OPENCODE_ZEN_CATALOG).length; + const measured = new Map(); + for (const model of Object.values(OPENCODE_ZEN_CATALOG)) { + const sdk = model.provider_npm ?? "(absent)"; + measured.set(sdk, (measured.get(sdk) ?? 0) + 1); + } + + expect(pairs.length).toBeGreaterThan(0); + for (const [count, of] of pairs) { + expect(of, "a row states a total the catalog does not have").toBe(total); + expect( + [...measured.values()], + `${count} of ${of} matches no SDK count` + ).toContain(count); + } + // And the rows add up, so the table cannot disagree with itself. + expect(pairs.reduce((sum, pair) => sum + (pair[0] ?? 0), 0)).toBe(total); + }); + + it("holds the prose totals to the catalog, in both languages", async () => { + // The tables above are parsed; the totals in the sentences beside them were + // not, and every count this repository has had to correct lived there - the + // README said 84 for a catalog of 85, the plane table said 120 served where + // 85 are, and the Chinese README carried both. A number in a sentence is the + // easiest kind to forget and the one a reader trusts most, so it is parsed + // too. The regex allows the emphasis markers the number is wrapped in. + const zen = Object.keys(OPENCODE_ZEN_CATALOG).length; + const go = Object.keys(OPENCODE_GO_CATALOG).length; + for (const file of ["../README.md", "../README.zh-CN.md"]) { + const text = await readFile(new URL(file, import.meta.url), "utf8"); + const stated = [ + ...text.matchAll( + /(\d+)\*{0,2}\s*(?:active[^.]*models|个\*\*在用\*\*模型)/gu + ), + ].map((m) => Number(m[1] ?? 0)); + expect( + stated.length, + `${file} states the catalog total nowhere` + ).toBeGreaterThan(0); + for (const n of stated) { + expect(n, `${file} states a Zen total the catalog does not have`).toBe( + zen + ); + } + } + // "85 served / 120 listed" and "32 served / 36 listed": the served half is + // ours to assert, the listed half comes from models.dev and is not. + const planes = await readFile( + new URL("../docs/redirect-planes.md", import.meta.url), + "utf8" + ); + const served = [...planes.matchAll(/(\d+)\s*served\s*\//gu)].map((m) => + Number(m[1] ?? 0) + ); + expect(served).toEqual([zen, go]); + }); + + it("holds the plane-by-plane table to both catalogs", async () => { + // The third document with counts, and the one whose table is per plane: + // every cell is a count, and every cell was stale when this was written. + const text = await readFile( + new URL("../docs/redirect-planes.md", import.meta.url), + "utf8" + ); + const counted = (catalog: readonly { provider_npm?: string }[]) => { + const bySdk = new Map(); + for (const model of Object.values(catalog)) { + const sdk = model.provider_npm ?? "(none)"; + bySdk.set(sdk, (bySdk.get(sdk) ?? 0) + 1); + } + return bySdk; + }; + const zen = counted(OPENCODE_ZEN_CATALOG); + const go = counted(OPENCODE_GO_CATALOG); + + const rows: { go: number; sdk: string; zen: number }[] = []; + for (const line of text.split("\n")) { + const cells = line.split("|").map((cell) => cell.trim()); + // `| SDK | protocol | Zen | Go |` — five cells once split on the pipes. + if (cells.length !== 6 || cells[1] === "" || cells[1] === ":--") { + continue; + } + const sdk = + cells[1] === "_(none)_" + ? "(none)" + : /^`([^`]+)`$/u.exec(cells[1] ?? "")?.[1]; + const zenCount = Number(cells[3]?.replaceAll("*", "")); + const rawGo = cells[4]?.replaceAll("*", "") ?? ""; + const goCount = rawGo === "—" || rawGo === "-" ? 0 : Number(rawGo); + if ( + sdk === undefined || + !Number.isFinite(zenCount) || + !Number.isFinite(goCount) + ) { + continue; + } + rows.push({ go: goCount, sdk, zen: zenCount }); + } + + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.zen, `${row.sdk} on Zen`).toBe(zen.get(row.sdk) ?? 0); + expect(row.go, `${row.sdk} on Go`).toBe(go.get(row.sdk) ?? 0); + } + // Every SDK either plane names has a row, so a new one cannot be missed. + expect(rows.map((row) => row.sdk).toSorted()).toEqual( + [...new Set([...zen.keys(), ...go.keys()])].toSorted() + ); + }); + + it("states the same total it lists", async () => { + const text = await readme(); + const total = /Counts are the \*\*(\d+)\*\*/u.exec(text); + expect(total, "the README no longer states a total").not.toBeNull(); + expect(Number(total?.[1])).toBe(Object.keys(OPENCODE_ZEN_CATALOG).length); + // And the rows add up to it, so the table cannot disagree with its own + // sentence the way it did before this test existed. + const sum = [...documentedCounts(text).values()].reduce((a, b) => a + b, 0); + expect(sum).toBe(Number(total?.[1])); + }); +}); diff --git a/test/catalog-enrichment.test.ts b/test/catalog-enrichment.test.ts new file mode 100644 index 00000000..977ee5d3 --- /dev/null +++ b/test/catalog-enrichment.test.ts @@ -0,0 +1,397 @@ +/** + * OpenCode Model Catalog: enriching a `/models` listing. + * + * @module test/catalog-enrichment.test + */ +import assert from "node:assert/strict"; +import { AsyncLocalStorage } from "node:async_hooks"; + +import { describe, expect, it, vi } from "vitest"; + +import { + type ActiveTurnState, + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, + SESSION_HEADER, + enrichModelsResponse, + getLiveGoCatalog, + getLiveZenCatalog, + isGoModelsListingUrl, + isModelsListingUrl, + patchFetch, + resolveConfig, +} from "./support/src.ts"; +import { createCaptureFetch, headerOf, SESSION_RE } from "./test-helpers.ts"; + +describe("catalog · enriching a /models listing", () => { + it("identifies model listing URLs accurately", () => { + expect(isModelsListingUrl("https://opencode.ai/zen/go/v1/models")).toBe( + true + ); + expect( + isModelsListingUrl("https://opencode.ai/zen/v1/models?limit=50") + ).toBe(true); + expect(isGoModelsListingUrl("https://opencode.ai/zen/go/v1/models")).toBe( + true + ); + expect(isGoModelsListingUrl("https://opencode.ai/zen/v1/models")).toBe( + false + ); + // **The modern family's Go path.** The Go plane's base is the `inference` + // family now, and that path carries no `/models` of its own — the gateway + // answers 404 there and this enrichment is what fills it. Reading the plane + // by substring put it in the ZEN bucket, and the Go plane was then answered + // with the Zen catalog: every Zen model, and none of the Go plane's own. + expect( + isGoModelsListingUrl("https://opencode.ai/inference/go/openai/v1/models") + ).toBe(true); + expect( + isGoModelsListingUrl("https://opencode.ai/inference/openai/v1/models") + ).toBe(false); + // And the Zen google family is not the Go plane, though it starts the same. + expect( + isGoModelsListingUrl("https://opencode.ai/inference/google/v1beta/models") + ).toBe(false); + expect( + isModelsListingUrl("https://opencode.ai/zen/v1/chat/completions") + ).toBe(false); + expect(isModelsListingUrl("https://api.openai.com/v1/models")).toBe(false); + }); + + it("enriches a configured custom gateway, not just opencode.ai", () => { + // The gap this closes: `isModelsListingUrl` hardcoded `opencode.ai/zen` + // while `isOpenCodeRequest` already honoured `gatewayUrls`. A relay that + // matched every other OpenCode rule got the headers and the session id and + // then no catalog — the only symptom was a shorter model list, which reads + // as "fewer models" rather than as a broken rule. + const gateways = ["https://relay.example.com/opencode"]; + expect( + isModelsListingUrl( + "https://relay.example.com/opencode/v1/models", + gateways + ) + ).toBe(true); + // …and the default no longer matches it, because the marker replaced the + // constant: a custom gateway is opted into, not additionally allowed. + expect( + isModelsListingUrl("https://relay.example.com/opencode/v1/models") + ).toBe(false); + + // The Go plane is the PATH, so a relay's Go route is recognised too. + expect( + isGoModelsListingUrl( + "https://relay.example.com/opencode/go/v1/models", + gateways + ) + ).toBe(true); + expect( + isGoModelsListingUrl( + "https://relay.example.com/opencode/v1/models", + gateways + ) + ).toBe(false); + }); + + it("enriches a truncated gateway models response with full catalog metadata", async () => { + // Upstream gateway returned only 2 models, both missing name/context_window + const [described] = OPENCODE_GO_CATALOG; + assert.ok(described !== undefined, "the Go shim is empty"); + const rawGatewayPayload = { + data: [ + { id: described.id, object: "model" }, + { id: "custom-gateway-model", object: "model" }, + ], + object: "list", + }; + const mockResponse = Response.json(rawGatewayPayload); + + const enrichedResponse = await enrichModelsResponse( + "https://opencode.ai/zen/go/v1/models", + mockResponse + ); + expect(enrichedResponse.status).toBe(200); + + const json = (await enrichedResponse.json()) as { + data: Array<{ + context_window?: number; + id: string; + name?: string; + }>; + object: string; + }; + expect(json.object).toBe("list"); + // **The catalog surface seeds, and that is correct here.** `discoverModels` + // returns its installed catalog for a provider pi ships and only fetches an + // endpoint for one it does not, so this listing is what the endpoint offers — + // the surface a user browses, not the list a picker shows. The picker lists + // the plane profile's declared models (`planeProfileFor`, never widened); + // Settings' fetch on a served plane is what offers the catalog + // (`complementDiscoveredModels`). + expect(json.data.length).toBe(OPENCODE_GO_CATALOG.length + 1); + + // The values are the shim row's own, read from the row they describe. + const enriched = json.data.find((m) => m.id === described.id); + expect(enriched).toBeDefined(); + expect(enriched?.name).toBe(described.name); + expect(enriched?.context_window).toBe(described.context_window); + + const custom = json.data.find((m) => m.id === "custom-gateway-model"); + expect(custom).toBeDefined(); + expect(custom?.id).toBe("custom-gateway-model"); + + // An empty answer is answered with the catalog that endpoint stands for: the + // vendor's own listing is narrower than its catalog, and this is the surface + // that says what exists. + const zenResponse = await enrichModelsResponse( + "https://opencode.ai/zen/v1/models", + Response.json({ data: [], object: "list" }) + ); + const zenJson = (await zenResponse.json()) as { + data: Array<{ id: string }>; + }; + expect(zenJson.data.map((m) => m.id)).toEqual( + getLiveZenCatalog().map((m) => m.id) + ); + }); + + it("answers the modern Go listing with the Go catalog, not the Zen one", async () => { + // **This is the bug the Go plane's endpoint change exposed.** The Go plane's + // base is the `inference` family, and the adapter lists its models at + // `${base}/models` — a path the gateway does not serve, so the answer comes + // from here. The plane of that URL used to be read by substring, which knew + // neither `/inference/go` nor the boundary that separates it from + // `/inference/google`: the Go plane was answered with the ZEN catalog, and + // its own models — the Gemini contributor, the Muse contributor — were not + // in the answer at all. + const goResponse = await enrichModelsResponse( + "https://opencode.ai/inference/go/openai/v1/models", + // What the gateway actually answers there: a 404's body. + Response.json({ error: { message: "not found" } }, { status: 404 }) + ); + const goJson = (await goResponse.json()) as { + data: Array<{ id: string }>; + }; + // The ids only one plane serves, taken from the shim rather than named: a + // refresh moves the names, not the rule. + const zenIds = new Set(OPENCODE_ZEN_CATALOG.map((m) => m.id)); + const goIds = new Set(OPENCODE_GO_CATALOG.map((m) => m.id)); + const goOnly = [...goIds].filter((id) => !zenIds.has(id)); + const zenOnly = [...zenIds].filter((id) => !goIds.has(id)); + expect(goOnly.length).toBeGreaterThan(0); + expect(zenOnly.length).toBeGreaterThan(0); + const answered = new Set(goJson.data.map((m) => m.id)); + // The GO catalog's own rows are in it… + for (const id of goOnly) { + expect(answered.has(id), id).toBe(true); + } + // …and the Zen catalog's are not. + for (const id of zenOnly) { + expect(answered.has(id), id).toBe(false); + } + expect(goJson.data.length).toBe(getLiveGoCatalog().length); + // The fill is the listing, so it answers as one. + expect(goResponse.status).toBe(200); + }); + + it("keeps the Muse 1.2 rows the zen plane actually serves", async () => { + const goResponse = await enrichModelsResponse( + "https://opencode.ai/zen/go/v1/models", + Response.json({ + data: [ + { id: "beam-free", object: "model" }, + { id: "muse-spark-1.2-contributor", object: "model" }, + ], + object: "list", + }) + ); + const goJson = (await goResponse.json()) as { + data: Array<{ id: string }>; + }; + expect(goJson.data.some((m) => m.id === "beam-free")).toBe(true); + expect(goJson.data.some((m) => m.id === "muse-spark-1.2-contributor")).toBe( + true + ); + + const zenResponse = await enrichModelsResponse( + "https://opencode.ai/zen/v1/models", + Response.json({ + data: [ + { id: "beam-free", object: "model" }, + { id: "muse-spark-1.2", object: "model" }, + { id: "mimo-v2.6-flash-free", object: "model" }, + ], + object: "list", + }) + ); + const zenJson = (await zenResponse.json()) as { + data: Array<{ id: string }>; + }; + expect(zenJson.data.some((m) => m.id === "beam-free")).toBe(true); + // Every row the gateway lists is kept and described, whatever the registry + // says about it: enrichModelsResponse applies no retirement filter. + // Retirement acts on the catalog SEED (parsing drops a `status: + // "deprecated"` row) and on the discovery merge (isDeprecatedModelOn), + // never on the gateway's own answer, so no refresh can drop these ids here. + expect(zenJson.data.some((m) => m.id === "muse-spark-1.2")).toBe(true); + expect(zenJson.data.some((m) => m.id === "mimo-v2.6-flash-free")).toBe( + true + ); + }); + + it("patchFetch transparently enriches GET .../models calls", async () => { + const als = new AsyncLocalStorage(); + const [described] = OPENCODE_GO_CATALOG; + assert.ok(described !== undefined, "the Go shim is empty"); + const mockFetch = vi.fn().mockImplementation(async (url: string) => { + if (url.includes("/models")) { + return Response.json({ + data: [{ id: described.id, object: "model" }], + object: "list", + }); + } + return new Response("OK", { status: 200 }); + }); + + const patched = patchFetch(mockFetch, als, resolveConfig()); + const res = await patched("https://opencode.ai/zen/go/v1/models"); + expect(res.status).toBe(200); + + const json = (await res.json()) as { + data: Array<{ id: string; name?: string; context_window?: number }>; + }; + // The catalog, with the gateway's one row enriched inside it. + expect(json.data.length).toBeGreaterThanOrEqual(OPENCODE_GO_CATALOG.length); + const row = json.data.find((m) => m.id === described.id); + expect(row?.name).toBe(described.name); + expect(row?.context_window).toBe(described.context_window); + }); + + it("leaves the raw gateway listing alone when enrichModels is off", async () => { + const als = new AsyncLocalStorage(); + // The gateway advertises one bare model with no name or limits. + const upstream = { + data: [{ id: "deepseek-v4-flash", object: "model" }], + object: "list", + }; + const mockFetch = vi + .fn() + .mockImplementation(async () => Response.json(upstream)); + + const patched = patchFetch( + mockFetch, + als, + resolveConfig({ enrichModels: false }) + ); + const response = await patched("https://opencode.ai/zen/go/v1/models"); + const json = (await response.json()) as typeof upstream; + + // Byte-for-byte the gateway's answer: no catalog rows merged in, and no + // name/context back-filled onto the one model it did advertise. + expect(json).toEqual(upstream); + }); + + it("still fixes session headers when enrichModels is off", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch( + mockFetch, + als, + resolveConfig({ enrichModels: false }) + ); + + await patched("https://opencode.ai/zen/go/v1/models"); + // Turning off the catalog must not disable the header repair this plugin + // exists for. + expect(headerOf(capture.init, SESSION_HEADER)).toMatch(SESSION_RE); + }); +}); + +describe("enrichModelsResponse: the gateway's verdict passes through", () => { + const url = "https://opencode.ai/zen/v1/models"; + + it("enrichModelsResponse: a 401, a 403, a 429 and a 5xx reach the caller as the gateway sent them", async () => { + // A refused key, a refused tier and an outage are answers about the + // REQUEST. Filling them with the catalog told discovery the listing had + // succeeded, and the reason the gateway gave never reached anyone. + const rows = [ + { + body: JSON.stringify({ error: { message: "Missing API key" } }), + status: 401, + type: "application/json", + }, + { + body: JSON.stringify({ error: { type: "FreeTierError" } }), + status: 403, + type: "application/json", + }, + // Not an auth answer and not an outage: the rule is "every non-2xx but + // 404", and this row records that a rate limit is the gateway's too. + { + body: JSON.stringify({ error: { type: "RateLimitError" } }), + headers: { "retry-after": "30" }, + status: 429, + type: "application/json", + }, + { body: "upstream down", status: 503, type: "text/plain" }, + ]; + for (const row of rows) { + const extra: Record = + "headers" in row && row.headers !== undefined ? row.headers : {}; + const upstream = new Response(row.body, { + headers: { "content-type": row.type, ...extra }, + status: row.status, + }); + const out = await enrichModelsResponse(url, upstream); + expect(out.status).toBe(row.status); + expect(await out.text()).toBe(row.body); + expect(out.headers.get("content-type")).toBe(row.type); + for (const [name, value] of Object.entries(extra)) { + expect(out.headers.get(name)).toBe(value); + } + } + }); + + it("fills a Zen-plane 404 with the Zen catalog, as it does the Go plane's", async () => { + const out = await enrichModelsResponse( + url, + new Response("Not Found", { status: 404 }) + ); + expect(out.status).toBe(200); + const body = (await out.json()) as { data: unknown[] }; + expect(body.data.length).toBe(getLiveZenCatalog().length); + }); + + it("still answers a 200 whose body is not JSON with the full catalog", async () => { + const out = await enrichModelsResponse( + url, + new Response("not json", { status: 200 }) + ); + expect(out.status).toBe(200); + const body = (await out.json()) as { data: unknown[] }; + expect(body.data.length).toBe(getLiveZenCatalog().length); + }); +}); + +describe("enrichModelsResponse: a model neither source describes", () => { + it("still describes it fully, so the picker never shows a row with no limits", async () => { + // The gateway adds models before models.dev — or the bundled shim — knows + // them. DSH sizes its context meter from these numbers, and the live e2e + // asserts every row the picker sees carries them; an `undefined` here + // reaches the UI as a model with no limits at all. + const url = "https://opencode.ai/zen/v1/models"; + const upstream = Response.json({ + data: [{ id: "brand-new-model-nobody-knows", object: "model" }], + }); + + const response = await enrichModelsResponse(url, upstream); + const body = (await response.json()) as { data: Record[] }; + const row = body.data.find( + (entry) => entry.id === "brand-new-model-nobody-knows" + ); + expect(row).toBeDefined(); + expect(typeof row?.context_window).toBe("number"); + expect(typeof row?.max_output_tokens).toBe("number"); + expect(row?.context_window).toBeGreaterThan(0); + expect(row?.max_output_tokens).toBeGreaterThan(0); + }); +}); diff --git a/test/catalog-refresh.test.ts b/test/catalog-refresh.test.ts new file mode 100644 index 00000000..36809a0c --- /dev/null +++ b/test/catalog-refresh.test.ts @@ -0,0 +1,216 @@ +/** + * OpenCode Model Catalog: reading, refreshing and resetting the live catalog. + * + * @module test/catalog-refresh.test + */ +import { describe, expect, it, vi } from "vitest"; + +import { + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, + getLiveGoCatalog, + getLiveZenCatalog, + parseModelsDevCatalog, + refreshCatalog, + resetCatalogState, +} from "./support/src.ts"; + +describe("catalog · the live registry read", () => { + it("parseModelsDevCatalog handles malformed data gracefully", () => { + // `api` and `familyApi` are carried alongside the arrays and are empty when + // nothing declares them — not `undefined`, so callers never guard two shapes. + const empty = { + api: { go: "", zen: "" }, + familyApi: { go: {}, zen: {} }, + go: [], + zen: [], + }; + expect(parseModelsDevCatalog(null)).toEqual(empty); + expect(parseModelsDevCatalog({})).toEqual(empty); + expect(parseModelsDevCatalog({ "opencode-go": {} })).toEqual(empty); + expect( + parseModelsDevCatalog({ "opencode-go": { models: "invalid" } }) + ).toEqual(empty); + }); + + it("drops deprecated models from a refresh so they cannot return", () => { + // The shim ships active models only; the refresh has to agree, or a + // revalidation would re-add rows the gateway no longer serves. + const raw = { + "opencode-go": { + models: { + "still-served": { name: "Still Served" }, + "gone-away": { name: "Gone Away", status: "deprecated" }, + }, + }, + opencode: { + models: { + "zen-live": { name: "Zen Live" }, + "zen-retired": { name: "Zen Retired", status: "deprecated" }, + }, + }, + }; + const parsed = parseModelsDevCatalog(raw); + expect(parsed.go.map((m) => m.id)).toEqual(["still-served"]); + expect(parsed.zen.map((m) => m.id)).toEqual(["zen-live"]); + }); + + it("parseModelsDevCatalog extracts complete model specifications for Go and Zen", () => { + const raw = { + "opencode-go": { + models: { + "new-preview-model": { + limit: { context: 2000000, output: 256000 }, + modalities: { input: ["text", "image"] }, + name: "New Preview Model", + }, + }, + }, + opencode: { + models: { + "zen-free-test": { + cost: { input: 0, output: 0 }, + limit: { context: 1000000, output: 128000 }, + name: "Zen Free Test", + }, + }, + }, + }; + const parsed = parseModelsDevCatalog(raw); + expect(parsed.go.length).toBe(1); + expect(parsed.go[0]).toEqual({ + context_window: 2000000, + id: "new-preview-model", + input_modalities: ["text", "image"], + max_output_tokens: 256000, + name: "New Preview Model", + }); + expect(parsed.zen.length).toBe(1); + expect(parsed.zen[0]?.is_free).toBe(true); + expect(parsed.zen[0]?.name).toBe("Zen Free Test"); + }); + + it("getLiveGoCatalog and getLiveZenCatalog return local shims and revalidate", async () => { + const initialGo = getLiveGoCatalog(); + expect(initialGo.length).toBe(OPENCODE_GO_CATALOG.length); + const initialZen = getLiveZenCatalog(); + // Equal to the generated data: the shim was regenerated, so the retired ids + // (`claude-sonnet-4`, and the three dead rows) are no longer IN it. Retirement + // still filters at load; it just has nothing left to remove from this set. + expect(initialZen.length).toBe(OPENCODE_ZEN_CATALOG.length); + + const mockFetch = vi.fn().mockResolvedValue( + Response.json({ + "opencode-go": { + models: { + "future-test-model": { + limit: { context: 1500000, output: 200000 }, + modalities: { input: ["text"] }, + name: "Future Test Model", + }, + "muse-spark-1.2-contributor": { + name: "Muse Spark 1.2 Contributor", + }, + }, + }, + opencode: { + models: { + "future-zen-model": { + name: "Future Zen Model", + }, + "muse-spark-1.2": { + name: "Muse Spark 1.2", + }, + }, + }, + }) + ); + + const updated = await refreshCatalog( + mockFetch as unknown as typeof fetch, + true + ); + expect(updated.go.length).toBe(OPENCODE_GO_CATALOG.length + 1); + // ONE zen row is new to the merged map: the mock's `future-zen-model`. Its + // `muse-spark-1.2` row is already in the regenerated shim, so it is not an + // addition. The parser's deprecation filter is the only filter, and the + // mock carries none, so nothing else can change the count. + expect(updated.zen.length).toBe(OPENCODE_ZEN_CATALOG.length + 1); + + const futureModel = updated.go.find((m) => m.id === "future-test-model"); + expect(futureModel).toBeDefined(); + expect(futureModel?.name).toBe("Future Test Model"); + expect(futureModel?.context_window).toBe(1500000); + expect(updated.go.some((m) => m.id === "muse-spark-1.2-contributor")).toBe( + true + ); + expect(updated.zen.some((m) => m.id === "muse-spark-1.2")).toBe(true); + }); + + it("refreshes from the OpenCode registry host, not a third-party mirror", async () => { + // Pinned because nothing else would notice a silent re-point: the two hosts + // serve the SAME object today (identical ETag), so a wrong URL still returns + // valid data — which is exactly why the assertion is on the request, not on + // the answer. + const seen: string[] = []; + const recordingFetch = vi.fn((input: unknown) => { + seen.push(String(input)); + return Promise.resolve({ + json: async () => ({ opencode: { models: {} } }), + ok: true, + }); + }); + + await refreshCatalog(recordingFetch as unknown as typeof fetch, true); + + expect(seen).toHaveLength(1); + expect(seen[0]).toBe("https://models.opencode.ai/api.json"); + expect(seen[0]).not.toContain("models.dev"); + }); + + it("resetCatalogState puts a refreshed catalog back to the bundled shim", async () => { + // The per-test reset in `test/support/` relies on this, and a reset that + // restored nothing would let every catalog count depend on run order again. + await refreshCatalog( + (async () => + Response.json({ + "opencode-go": { + models: { "reset-probe-model": { name: "Reset Probe" } }, + }, + })) as unknown as typeof fetch, + true + ); + expect(getLiveGoCatalog().some((m) => m.id === "reset-probe-model")).toBe( + true + ); + resetCatalogState(); + expect(getLiveGoCatalog().some((m) => m.id === "reset-probe-model")).toBe( + false + ); + expect(getLiveGoCatalog()).toHaveLength(OPENCODE_GO_CATALOG.length); + expect(getLiveZenCatalog()).toHaveLength(OPENCODE_ZEN_CATALOG.length); + }); + + it("refreshCatalog degrades gracefully on network errors without throwing", async () => { + const before = { + go: getLiveGoCatalog().length, + zen: getLiveZenCatalog().length, + }; + const mockFailingFetch = vi + .fn() + .mockRejectedValue(new Error("Network timeout")); + + const catalog = await refreshCatalog( + mockFailingFetch as unknown as typeof fetch, + true + ); + + // A failed refresh must resolve (never throw) and leave the active + // catalog exactly as it was, so the meter keeps working offline. + expect(catalog.go.length).toBe(before.go); + expect(catalog.zen.length).toBe(before.zen); + expect(catalog.go.length).toBeGreaterThanOrEqual( + OPENCODE_GO_CATALOG.length + ); + }); +}); diff --git a/test/catalog-routing.test.ts b/test/catalog-routing.test.ts new file mode 100644 index 00000000..697f071a --- /dev/null +++ b/test/catalog-routing.test.ts @@ -0,0 +1,198 @@ +/** + * OpenCode Model Catalog: every model is routable, on its own plane's base. + * + * @module test/catalog-routing.test + */ +import { describe, expect, it } from "vitest"; + +import { catalogRow, findCatalogRow } from "./support/catalog-fixture.ts"; +import { + OPENCODE_FAMILY_API, + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, + PROTOCOL_FOR_SDK, + protocolForSdk, + isServableSdk, + planeModelFor, +} from "./support/src.ts"; + +/** + * Every SDK the generated catalog names must be CLASSIFIED - either mapped to a + * protocol or listed as deliberately unserved. The failure this prevents is + * silent: models.dev gains a new SDK, the freshness gate regenerates the shim + * and passes, and the model disappears from the picker because the servability + * check treats "unknown" as "unservable". + */ + +describe("catalog · SDK coverage", () => { + it("classifies every provider SDK the catalog names", () => { + const named = new Set(); + for (const plane of [OPENCODE_ZEN_CATALOG, OPENCODE_GO_CATALOG]) { + for (const model of Object.values(plane)) { + const sdk = (model as { provider_npm?: string }).provider_npm; + if (typeof sdk === "string" && sdk.length > 0) { + named.add(sdk); + } + } + } + // Non-vacuous: the catalogs name more than one SDK. + expect(named.size).toBeGreaterThan(1); + const classified = new Set(Object.keys(PROTOCOL_FOR_SDK)); + expect([...named].filter((sdk) => !classified.has(sdk))).toEqual([]); + expect([...named].filter((sdk) => !isServableSdk(sdk))).toEqual([]); + }); +}); + +/** A plane's model as the plane adapter would build it, on the default family. */ +const servedOn = ( + route: "opencode" | "opencode-go", + id: string, + routeApi?: string +) => { + const catalog = + route === "opencode" ? OPENCODE_ZEN_CATALOG : OPENCODE_GO_CATALOG; + const spec = catalog.find((candidate) => candidate.id === id); + const result = planeModelFor({ + defaults: { contextWindow: 262_144, input: ["text"], maxTokens: 32_768 }, + endpoint: "modern", + entry: { id }, + familyApi: + route === "opencode" ? OPENCODE_FAMILY_API.zen : OPENCODE_FAMILY_API.go, + route, + ...(routeApi === undefined ? {} : { routeApi }), + ...(spec === undefined ? {} : { spec }), + }); + if (result.model === undefined) { + throw new Error(`${route}/${id}: ${result.error}`); + } + return result.model; +}; + +/** + * INVARIANT I1 - every model the plugin offers is routable. + * + * The picker shows both planes' models. A model naming an SDK is served on that + * SDK's protocol, on ITS OWN plane: the Go plane's base URL is not Zen's. One + * route per plane carries every protocol (D13), so the check is that each + * catalog model resolves to a protocol and to a base on its own plane. + */ +describe("catalog · plane and SDK coverage", () => { + it("serves every catalog model on its own plane's base, whatever its SDK", () => { + const gaps: string[] = []; + const planes = [ + ["opencode", OPENCODE_ZEN_CATALOG], + ["opencode-go", OPENCODE_GO_CATALOG], + ] as const; + let checked = 0; + for (const [plane, catalog] of planes) { + for (const spec of catalog) { + const model = servedOn(plane, spec.id); + checked += 1; + const onGo = new URL(model.baseUrl).pathname.startsWith( + "/inference/go/" + ); + if (model.api !== protocolForSdk(spec.provider_npm)) { + gaps.push(`${plane}/${spec.id}: ${model.api}`); + } + if (onGo !== (plane === "opencode-go")) { + gaps.push(`${plane}/${spec.id}: ${model.baseUrl}`); + } + } + } + expect(checked).toBe( + OPENCODE_ZEN_CATALOG.length + OPENCODE_GO_CATALOG.length + ); + expect(gaps).toEqual([]); + }); +}); + +/** + * The base each (plane, protocol) is served on, on the default family. The + * rows are picked by shape (`findCatalogRow`), never by a vendor id a refresh + * can retire; only the bases are pinned, and a cell whose plane no longer + * carries a row on that protocol is skipped rather than failed. + */ +const PINNED_BASES = [ + ["opencode", "openai-responses", "https://opencode.ai/inference/openai/v1"], + [ + "opencode-go", + "openai-responses", + "https://opencode.ai/inference/go/openai/v1", + ], + [ + "opencode-go", + "anthropic-messages", + "https://opencode.ai/inference/go/anthropic", + ], + [ + "opencode-go", + "google-generative-ai", + "https://opencode.ai/inference/go/google/v1beta", + ], +] as const; + +/** + * The plane is half of the routing decision. An SDK says which shape a model + * needs; only the route the caller selected says which account and base URL + * serve it. + */ +describe("routing · the plane half of the decision", () => { + it("sends the same SDK to a different base per plane", () => { + // A pinned base is a property of the routing table, not of the vendor + // catalog: a refresh that retires a plane's last row on one protocol + // skips that cell rather than failing it. `plane-model.test.ts` pins + // every (plane, protocol) base catalog-free through `baseUrlFor`. + let checked = 0; + for (const [route, api, baseUrl] of PINNED_BASES) { + const row = findCatalogRow(route, api); + if (row === undefined) { + continue; + } + expect(servedOn(route, row.id), `${route}/${row.id}`).toMatchObject({ + api, + baseUrl, + }); + checked += 1; + } + expect(checked, "no pinned cell has a catalogued row").toBeGreaterThan(0); + }); +}); + +/** + * The default format. A model that speaks completions has no SDK of its own, + * and an id nobody catalogued is served the same way (D15): a name is not a + * protocol, so `claude-unlisted-9` is not guessed onto Messages. + */ +describe("routing · the default format", () => { + it("serves a model with no SDK of its own as completions", () => { + let checked = 0; + for (const route of ["opencode", "opencode-go"] as const) { + // A plane whose shim names an SDK for every row has nothing to check. + const row = findCatalogRow(route); + if (row !== undefined) { + expect(servedOn(route, row.id).api, `${route}/${row.id}`).toBe( + "openai-completions" + ); + checked += 1; + } + } + expect(checked, "no plane carries a row without an SDK").toBeGreaterThan(0); + }); + + it("serves an id the catalog does not describe as completions, unless the route names a protocol", () => { + expect(servedOn("opencode", "claude-unlisted-9").api).toBe( + "openai-completions" + ); + expect(servedOn("opencode", "gemini-unlisted-9").api).toBe( + "openai-completions" + ); + expect( + servedOn("opencode", "claude-unlisted-9", "anthropic-messages").api + ).toBe("anthropic-messages"); + // A route-level api never reclassifies an id the catalog describes. + const responses = catalogRow("opencode", "openai-responses").id; + expect(servedOn("opencode", responses, "anthropic-messages").api).toBe( + "openai-responses" + ); + }); +}); diff --git a/test/catalog.test.ts b/test/catalog.test.ts index dd7b1d9e..5b9bb341 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -1,62 +1,85 @@ -import assert from "node:assert/strict"; /** - * OpenCode Model Catalog & Enrichment. + * OpenCode Model Catalog: the bundled shim, as invariants. * - * Split out of the former monolithic `plugin.test.ts`. + * Every assertion here is a property of the generated shim — non-empty, unique + * ids, the `is_free` rule the generator applies, a paid row on every protocol a + * plane serves — never a count, which breaks on every vendor refresh by + * construction. Retired ids stay named: absence does not break on a refresh. + * A row a case needs is picked by its shape (a priced Go row, a free Zen + * row), never by a vendor id a refresh can retire; the one case whose point + * IS a named id skips, by name, once the catalog no longer carries it. + * Its siblings: `catalog-enrichment` (the `/models` fill), `catalog-refresh` + * (the live registry read), `catalog-docs` (the README's table) and + * `catalog-routing` (protocol and plane coverage). * * @module test/catalog.test */ -import { AsyncLocalStorage } from "node:async_hooks"; -import { readFile } from "node:fs/promises"; +import assert from "node:assert/strict"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { - type ActiveTurnState, OPENCODE_FAMILY_API, OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, - PROTOCOL_FOR_SDK, protocolForSdk, - SESSION_HEADER, - enrichModelsResponse, findModelSpec, - getLiveGoCatalog, - getLiveZenCatalog, - isGoModelsListingUrl, - isModelsListingUrl, isServableSdk, parseModelsDevCatalog, - planeModelFor, - patchFetch, - refreshCatalog, - resetCatalogState, - resolveConfig, sanitizeModalities, } from "./support/src.ts"; -import { createCaptureFetch, headerOf, SESSION_RE } from "./test-helpers.ts"; -afterEach(() => { - vi.unstubAllGlobals(); - delete process.env.OPENCODE_SESSION_ID; -}); +/** The paid id the registry retires on zen and keeps on go. */ +const MUSE = "muse-spark-1.2-contributor"; + +/** + * Every row of a plane's shim names a protocol the plane adapter serves, and + * each protocol it serves has a paid row to serve, beside at least one free + * row overall: the cold-start picker always offers both tiers. + */ +const expectServedOnEveryProtocol = ( + rows: readonly { id: string; is_free?: boolean; provider_npm?: string }[] +): void => { + const served = new Set(rows.map((row) => protocolForSdk(row.provider_npm))); + expect(served.has(undefined)).toBe(false); + expect(served.size).toBeGreaterThan(0); + for (const protocol of served) { + expect( + rows.some( + (row) => + protocolForSdk(row.provider_npm) === protocol && row.is_free !== true + ), + `${String(protocol)} has a paid row` + ).toBe(true); + } + expect(rows.some((row) => row.is_free === true)).toBe(true); + expect(rows.some((row) => row.is_free !== true)).toBe(true); +}; -describe("OpenCode Model Catalog & Enrichment", () => { - it("keeps a retained id fully described, not merely present", () => { +describe("OpenCode model catalog · the bundled shim", () => { + it("keeps every free row fully described, not merely present, and alike on both planes", () => { // A free row is offered to a user, so it has to carry what a picker - // renders. `beam-free` is the active free model both planes serve, so it - // is the fixture here — and its metadata survives with it: context, - // output cap, modalities and the thinking levels, because an id without - // its fields is a row that cannot be priced or reasoned about. + // renders: context, output cap, modalities and a name, because an id + // without its fields is a row that cannot be priced or reasoned about. + // A free id both planes serve is described the same on each. + const go = new Map(OPENCODE_GO_CATALOG.map((m) => [m.id, m])); for (const catalog of [OPENCODE_ZEN_CATALOG, OPENCODE_GO_CATALOG]) { - const spec = catalog.find((m) => m.id === "beam-free"); - expect(spec).toBeDefined(); - expect(spec?.context_window).toBeGreaterThan(0); - expect(spec?.max_output_tokens).toBeGreaterThan(0); - expect(spec?.input_modalities.length).toBeGreaterThan(0); - expect(spec?.reasoning).toBe(true); - expect(spec?.reasoning_options?.length).toBeGreaterThan(0); - expect(spec?.name.length).toBeGreaterThan(0); + const free = catalog.filter((m) => m.is_free === true); + expect(free.length).toBeGreaterThan(0); + for (const spec of free) { + expect(spec.context_window, spec.id).toBeGreaterThan(0); + expect(spec.max_output_tokens, spec.id).toBeGreaterThan(0); + expect(spec.input_modalities.length, spec.id).toBeGreaterThan(0); + expect(spec.name.length, spec.id).toBeGreaterThan(0); + } + } + for (const zen of OPENCODE_ZEN_CATALOG.filter((m) => m.is_free === true)) { + const twin = go.get(zen.id); + if (twin !== undefined) { + expect(twin.context_window, zen.id).toBe(zen.context_window); + expect(twin.input_modalities, zen.id).toEqual(zen.input_modalities); + expect(twin.reasoning, zen.id).toBe(zen.reasoning); + } } }); @@ -78,17 +101,31 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(ids.has(id), `${id} resurrected in a plane's shim`).toBe(false); } } + }); + + it("scopes a retirement to the plane the registry retires it on", (context) => { // Retirement is provider-scoped by the registry's own data, not by a rule // of ours: the paid `muse-spark-1.2-contributor` is absent from zen and - // served on go. - const zen = new Set(OPENCODE_ZEN_CATALOG.map((m) => m.id)); + // served on go. The named id IS the point, so once go retires it too the + // case has nothing to show, and says so rather than failing a refresh. const go = new Set(OPENCODE_GO_CATALOG.map((m) => m.id)); - expect(zen.has("muse-spark-1.2-contributor")).toBe(false); - expect(go.has("muse-spark-1.2-contributor")).toBe(true); + if (!go.has(MUSE)) { + context.skip( + `${MUSE} left the Go shim: no provider-scoped retirement to show` + ); + } + const zen = new Set(OPENCODE_ZEN_CATALOG.map((m) => m.id)); + expect(zen.has(MUSE)).toBe(false); + expect(findModelSpec(MUSE)?.id).toBe(MUSE); }); it("ships only active, priced models in the bundled Go shim", () => { - expect(OPENCODE_GO_CATALOG.length).toBe(29); + // Invariants, not counts: a count breaks on every vendor refresh by + // construction, and says nothing a reader can act on when it does. + expect(OPENCODE_GO_CATALOG.length).toBeGreaterThan(0); + expect(new Set(OPENCODE_GO_CATALOG.map((m) => m.id)).size).toBe( + OPENCODE_GO_CATALOG.length + ); for (const model of OPENCODE_GO_CATALOG) { expect(model.id.length).toBeGreaterThan(0); expect(model.name.length).toBeGreaterThan(0); @@ -102,13 +139,7 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(model.cost?.output).toBeGreaterThanOrEqual(0); } - const ids = new Set(OPENCODE_GO_CATALOG.map((m) => m.id)); - expect(ids.has("deepseek-v4.1-flash")).toBe(true); - expect(ids.has("deepseek-v4-pro")).toBe(true); - expect(ids.has("qwen3.8-flash")).toBe(true); - expect(ids.has("grok-4.7")).toBe(true); - expect(ids.has("kimi-k3")).toBe(true); - expect(ids.has("mimo-v2.6-pro")).toBe(true); + expectServedOnEveryProtocol(OPENCODE_GO_CATALOG); }); it("excludes models the gateway no longer serves", () => { @@ -121,93 +152,29 @@ describe("OpenCode Model Catalog & Enrichment", () => { } }); - it("identifies model listing URLs accurately", () => { - expect(isModelsListingUrl("https://opencode.ai/zen/go/v1/models")).toBe( - true - ); - expect( - isModelsListingUrl("https://opencode.ai/zen/v1/models?limit=50") - ).toBe(true); - expect(isGoModelsListingUrl("https://opencode.ai/zen/go/v1/models")).toBe( - true - ); - expect(isGoModelsListingUrl("https://opencode.ai/zen/v1/models")).toBe( - false - ); - // **The modern family's Go path.** The Go plane's base is the `inference` - // family now, and that path carries no `/models` of its own — the gateway - // answers 404 there and this enrichment is what fills it. Reading the plane - // by substring put it in the ZEN bucket, and the Go plane was then answered - // with the Zen catalog: every Zen model, and none of the Go plane's own. - expect( - isGoModelsListingUrl("https://opencode.ai/inference/go/openai/v1/models") - ).toBe(true); - expect( - isGoModelsListingUrl("https://opencode.ai/inference/openai/v1/models") - ).toBe(false); - // And the Zen google family is not the Go plane, though it starts the same. - expect( - isGoModelsListingUrl("https://opencode.ai/inference/google/v1beta/models") - ).toBe(false); - expect( - isModelsListingUrl("https://opencode.ai/zen/v1/chat/completions") - ).toBe(false); - expect(isModelsListingUrl("https://api.openai.com/v1/models")).toBe(false); - }); - - it("enriches a configured custom gateway, not just opencode.ai", () => { - // The gap this closes: `isModelsListingUrl` hardcoded `opencode.ai/zen` - // while `isOpenCodeRequest` already honoured `gatewayUrls`. A relay that - // matched every other OpenCode rule got the headers and the session id and - // then no catalog — the only symptom was a shorter model list, which reads - // as "fewer models" rather than as a broken rule. - const gateways = ["https://relay.example.com/opencode"]; - expect( - isModelsListingUrl( - "https://relay.example.com/opencode/v1/models", - gateways - ) - ).toBe(true); - // …and the default no longer matches it, because the marker replaced the - // constant: a custom gateway is opted into, not additionally allowed. - expect( - isModelsListingUrl("https://relay.example.com/opencode/v1/models") - ).toBe(false); - - // The Go plane is the PATH, so a relay's Go route is recognised too. - expect( - isGoModelsListingUrl( - "https://relay.example.com/opencode/go/v1/models", - gateways - ) - ).toBe(true); - expect( - isGoModelsListingUrl( - "https://relay.example.com/opencode/v1/models", - gateways - ) - ).toBe(false); - }); - it("ships active Zen free tiers and flagships in the bundled shim", () => { // The shim answers before the first live refresh, and a refresh merges by // replacing the whole set — so these counts are the cold-start picker, and a // silent drop here is a model nobody can pick. Regenerate with // `scripts/regenerate-catalog-shim.ts` rather than editing by hand. - expect(OPENCODE_ZEN_CATALOG.length).toBe(82); + expect(OPENCODE_ZEN_CATALOG.length).toBeGreaterThan(0); + expect(new Set(OPENCODE_ZEN_CATALOG.map((m) => m.id)).size).toBe( + OPENCODE_ZEN_CATALOG.length + ); const freeModels = OPENCODE_ZEN_CATALOG.filter((m) => m.is_free === true); - // Only the free tiers the registry still lists as active. The count is the - // `is_free` flag the shim carries, NOT the `-free` suffix: `big-pickle` is - // flagged and is not suffixed, so a suffix rule would have missed it. - expect(freeModels.length).toBe(9); + expect(freeModels.length).toBeGreaterThan(0); + // The flag the shim carries is the generator's rule, exactly + // (`parseModelsDevCatalog`): a `free` id OR a zero price. NOT the suffix + // alone: `big-pickle` is flagged and is not suffixed. + for (const model of OPENCODE_ZEN_CATALOG) { + expect(model.is_free === true, model.id).toBe( + model.id.includes("free") || + (model.cost?.input === 0 && model.cost.output === 0) + ); + } + expectServedOnEveryProtocol(OPENCODE_ZEN_CATALOG); const ids = new Set(OPENCODE_ZEN_CATALOG.map((m) => m.id)); - expect(ids.has("beam-free")).toBe(true); - expect(ids.has("beam-free")).toBe(true); - expect(ids.has("mimo-v2.6-flash-free")).toBe(true); - expect(ids.has("claude-sonnet-4-5")).toBe(true); - expect(ids.has("gpt-5.4")).toBe(true); - expect(ids.has("gemini-3.8-flash")).toBe(true); // Deprecated Zen free tiers must not reappear. expect(ids.has("qwen3.6-plus-free")).toBe(false); expect(ids.has("minimax-m3-free")).toBe(false); @@ -237,8 +204,9 @@ describe("OpenCode Model Catalog & Enrichment", () => { // Every plane we declare a route for is present in the cold-start shim, so // the mount has models to serve before the first refresh ever runs. - expect(buckets.get("@ai-sdk/openai")?.length).toBe(31); - expect(buckets.get("@ai-sdk/anthropic")?.length).toBe(17); + expect([...buckets.keys()]).toEqual( + expect.arrayContaining(["@ai-sdk/openai", "@ai-sdk/anthropic"]) + ); // And each of those SDKs maps to a protocol the plane adapter serves, so no // carried model names a format with nowhere to go. `@ai-sdk/mistral` is no @@ -266,207 +234,9 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(byProtocol.has("unserved")).toBe(false); const responses = [...(byProtocol.get("openai-responses") ?? [])]; const anthropic = [...(byProtocol.get("anthropic-messages") ?? [])]; - expect(responses.length).toBe(33); - expect(anthropic.length).toBe(20); + expect(responses.length).toBeGreaterThan(0); + expect(anthropic.length).toBeGreaterThan(0); expect(responses.filter((id) => anthropic.includes(id))).toEqual([]); - expect(responses).toContain("muse-spark-1.2"); - expect(anthropic).toContain("claude-sonnet-4-5"); - }); - - it("enriches a truncated gateway models response with full catalog metadata", async () => { - // Upstream gateway returned only 2 models, both missing name/context_window - const rawGatewayPayload = { - data: [ - { id: "deepseek-v4.1-flash", object: "model" }, - { id: "custom-gateway-model", object: "model" }, - ], - object: "list", - }; - const mockResponse = Response.json(rawGatewayPayload); - - const enrichedResponse = await enrichModelsResponse( - "https://opencode.ai/zen/go/v1/models", - mockResponse - ); - expect(enrichedResponse.status).toBe(200); - - const json = (await enrichedResponse.json()) as { - data: Array<{ - context_window?: number; - id: string; - name?: string; - }>; - object: string; - }; - expect(json.object).toBe("list"); - // **The catalog surface seeds, and that is correct here.** `discoverModels` - // returns its installed catalog for a provider pi ships and only fetches an - // endpoint for one it does not, so this listing is what the endpoint offers — - // the surface a user browses, not the list a picker shows. The picker lists - // the plane profile's declared models (`planeProfileFor`, never widened); - // Settings' fetch on a served plane is what offers the catalog - // (`complementDiscoveredModels`). - expect(json.data.length).toBe(OPENCODE_GO_CATALOG.length + 1); - - const deepseek = json.data.find((m) => m.id === "deepseek-v4.1-flash"); - expect(deepseek).toBeDefined(); - expect(deepseek?.name).toBe("DeepSeek V4.1 Flash"); - expect(deepseek?.context_window).toBe(1000000); - - const custom = json.data.find((m) => m.id === "custom-gateway-model"); - expect(custom).toBeDefined(); - expect(custom?.id).toBe("custom-gateway-model"); - - // An empty answer is answered with the catalog that endpoint stands for: the - // vendor's own listing is narrower than its catalog, and this is the surface - // that says what exists. - const zenResponse = await enrichModelsResponse( - "https://opencode.ai/zen/v1/models", - Response.json({ data: [], object: "list" }) - ); - const zenJson = (await zenResponse.json()) as { - data: Array<{ id: string }>; - }; - expect(zenJson.data.length).toBe(getLiveZenCatalog().length); - expect(zenJson.data.some((m) => m.id === "beam-free")).toBe(true); - }); - - it("answers the modern Go listing with the Go catalog, not the Zen one", async () => { - // **This is the bug the Go plane's endpoint change exposed.** The Go plane's - // base is the `inference` family, and the adapter lists its models at - // `${base}/models` — a path the gateway does not serve, so the answer comes - // from here. The plane of that URL used to be read by substring, which knew - // neither `/inference/go` nor the boundary that separates it from - // `/inference/google`: the Go plane was answered with the ZEN catalog, and - // its own models — the Gemini contributor, the Muse contributor — were not - // in the answer at all. - const goResponse = await enrichModelsResponse( - "https://opencode.ai/inference/go/openai/v1/models", - // What the gateway actually answers there: a 404's body. - Response.json({ error: { message: "not found" } }, { status: 404 }) - ); - const goJson = (await goResponse.json()) as { - data: Array<{ id: string }>; - }; - // The GO catalog's own rows are in it… - expect(goJson.data.some((m) => m.id === "gemini-4-argon-contributor")).toBe( - true - ); - expect(goJson.data.some((m) => m.id === "muse-spark-1.3-contributor")).toBe( - true - ); - // …and the Zen catalog's are not. - expect(goJson.data.some((m) => m.id === "claude-opus-4-6")).toBe(false); - expect(goJson.data.length).toBe(getLiveGoCatalog().length); - // The fill is the listing, so it answers as one. - expect(goResponse.status).toBe(200); - }); - - it("keeps the Muse 1.2 rows the zen plane actually serves", async () => { - const goResponse = await enrichModelsResponse( - "https://opencode.ai/zen/go/v1/models", - Response.json({ - data: [ - { id: "beam-free", object: "model" }, - { id: "muse-spark-1.2-contributor", object: "model" }, - ], - object: "list", - }) - ); - const goJson = (await goResponse.json()) as { - data: Array<{ id: string }>; - }; - expect(goJson.data.some((m) => m.id === "beam-free")).toBe(true); - expect(goJson.data.some((m) => m.id === "muse-spark-1.2-contributor")).toBe( - true - ); - - const zenResponse = await enrichModelsResponse( - "https://opencode.ai/zen/v1/models", - Response.json({ - data: [ - { id: "beam-free", object: "model" }, - { id: "muse-spark-1.2", object: "model" }, - { id: "mimo-v2.6-flash-free", object: "model" }, - ], - object: "list", - }) - ); - const zenJson = (await zenResponse.json()) as { - data: Array<{ id: string }>; - }; - expect(zenJson.data.some((m) => m.id === "beam-free")).toBe(true); - // Active catalog rows the gateway lists are kept: enrichment strips only - // what the REGISTRY retired, and none of these carries that flag. - expect(zenJson.data.some((m) => m.id === "muse-spark-1.2")).toBe(true); - expect(zenJson.data.some((m) => m.id === "mimo-v2.6-flash-free")).toBe( - true - ); - }); - - it("patchFetch transparently enriches GET .../models calls", async () => { - const als = new AsyncLocalStorage(); - const mockFetch = vi.fn().mockImplementation(async (url: string) => { - if (url.includes("/models")) { - return Response.json({ - data: [{ id: "deepseek-v4.1-flash", object: "model" }], - object: "list", - }); - } - return new Response("OK", { status: 200 }); - }); - - const patched = patchFetch(mockFetch, als, resolveConfig()); - const res = await patched("https://opencode.ai/zen/go/v1/models"); - expect(res.status).toBe(200); - - const json = (await res.json()) as { - data: Array<{ id: string; name?: string; context_window?: number }>; - }; - // The catalog, with the gateway's one row enriched inside it. - expect(json.data.length).toBeGreaterThanOrEqual(OPENCODE_GO_CATALOG.length); - const row = json.data.find((m) => m.id === "deepseek-v4.1-flash"); - expect(row?.name).toBe("DeepSeek V4.1 Flash"); - expect(row?.context_window).toBe(1_000_000); - }); - - it("leaves the raw gateway listing alone when enrichModels is off", async () => { - const als = new AsyncLocalStorage(); - // The gateway advertises one bare model with no name or limits. - const upstream = { - data: [{ id: "deepseek-v4-flash", object: "model" }], - object: "list", - }; - const mockFetch = vi - .fn() - .mockImplementation(async () => Response.json(upstream)); - - const patched = patchFetch( - mockFetch, - als, - resolveConfig({ enrichModels: false }) - ); - const response = await patched("https://opencode.ai/zen/go/v1/models"); - const json = (await response.json()) as typeof upstream; - - // Byte-for-byte the gateway's answer: no catalog rows merged in, and no - // name/context back-filled onto the one model it did advertise. - expect(json).toEqual(upstream); - }); - - it("still fixes session headers when enrichModels is off", async () => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - const patched = patchFetch( - mockFetch, - als, - resolveConfig({ enrichModels: false }) - ); - - await patched("https://opencode.ai/zen/go/v1/models"); - // Turning off the catalog must not disable the header repair this plugin - // exists for. - expect(headerOf(capture.init, SESSION_HEADER)).toMatch(SESSION_RE); }); it("declares a distinct endpoint per wire family, per plane", () => { @@ -484,23 +254,6 @@ describe("OpenCode Model Catalog & Enrichment", () => { } }); - it("parseModelsDevCatalog handles malformed data gracefully", () => { - // `api` and `familyApi` are carried alongside the arrays and are empty when - // nothing declares them — not `undefined`, so callers never guard two shapes. - const empty = { - api: { go: "", zen: "" }, - familyApi: { go: {}, zen: {} }, - go: [], - zen: [], - }; - expect(parseModelsDevCatalog(null)).toEqual(empty); - expect(parseModelsDevCatalog({})).toEqual(empty); - expect(parseModelsDevCatalog({ "opencode-go": {} })).toEqual(empty); - expect( - parseModelsDevCatalog({ "opencode-go": { models: "invalid" } }) - ).toEqual(empty); - }); - it("carries each model's thinking level, in all three shapes", () => { // models.dev describes the setting as `reasoning` plus `reasoning_options`, // and the three shapes it uses differ: effort with values, a token budget @@ -568,187 +321,6 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(bare.api).toEqual({ go: "", zen: "" }); }); - it("drops deprecated models from a refresh so they cannot return", () => { - // The shim ships active models only; the refresh has to agree, or a - // revalidation would re-add rows the gateway no longer serves. - const raw = { - "opencode-go": { - models: { - "still-served": { name: "Still Served" }, - "gone-away": { name: "Gone Away", status: "deprecated" }, - }, - }, - opencode: { - models: { - "zen-live": { name: "Zen Live" }, - "zen-retired": { name: "Zen Retired", status: "deprecated" }, - }, - }, - }; - const parsed = parseModelsDevCatalog(raw); - expect(parsed.go.map((m) => m.id)).toEqual(["still-served"]); - expect(parsed.zen.map((m) => m.id)).toEqual(["zen-live"]); - }); - - it("parseModelsDevCatalog extracts complete model specifications for Go and Zen", () => { - const raw = { - "opencode-go": { - models: { - "new-preview-model": { - limit: { context: 2000000, output: 256000 }, - modalities: { input: ["text", "image"] }, - name: "New Preview Model", - }, - }, - }, - opencode: { - models: { - "zen-free-test": { - cost: { input: 0, output: 0 }, - limit: { context: 1000000, output: 128000 }, - name: "Zen Free Test", - }, - }, - }, - }; - const parsed = parseModelsDevCatalog(raw); - expect(parsed.go.length).toBe(1); - expect(parsed.go[0]).toEqual({ - context_window: 2000000, - id: "new-preview-model", - input_modalities: ["text", "image"], - max_output_tokens: 256000, - name: "New Preview Model", - }); - expect(parsed.zen.length).toBe(1); - expect(parsed.zen[0]?.is_free).toBe(true); - expect(parsed.zen[0]?.name).toBe("Zen Free Test"); - }); - - it("getLiveGoCatalog and getLiveZenCatalog return local shims and revalidate", async () => { - const initialGo = getLiveGoCatalog(); - expect(initialGo.length).toBe(OPENCODE_GO_CATALOG.length); - const initialZen = getLiveZenCatalog(); - // Equal to the generated data: the shim was regenerated, so the retired ids - // (`claude-sonnet-4`, and the three dead rows) are no longer IN it. Retirement - // still filters at load; it just has nothing left to remove from this set. - expect(initialZen.length).toBe(OPENCODE_ZEN_CATALOG.length); - - const mockFetch = vi.fn().mockResolvedValue( - Response.json({ - "opencode-go": { - models: { - "future-test-model": { - limit: { context: 1500000, output: 200000 }, - modalities: { input: ["text"] }, - name: "Future Test Model", - }, - "muse-spark-1.2-contributor": { - name: "Muse Spark 1.2 Contributor", - }, - }, - }, - opencode: { - models: { - "future-zen-model": { - name: "Future Zen Model", - }, - "muse-spark-1.2": { - name: "Muse Spark 1.2", - }, - }, - }, - }) - ); - - const updated = await refreshCatalog( - mockFetch as unknown as typeof fetch, - true - ); - expect(updated.go.length).toBe(OPENCODE_GO_CATALOG.length + 1); - // ONE zen row is new to the merged map: the mock's `future-zen-model`. Its - // `muse-spark-1.2` row is already in the regenerated shim, so it is not an - // addition. The parser's deprecation filter is the only filter, and the - // mock carries none, so nothing else can change the count. - expect(updated.zen.length).toBe(OPENCODE_ZEN_CATALOG.length + 1); - - const futureModel = updated.go.find((m) => m.id === "future-test-model"); - expect(futureModel).toBeDefined(); - expect(futureModel?.name).toBe("Future Test Model"); - expect(futureModel?.context_window).toBe(1500000); - expect(updated.go.some((m) => m.id === "muse-spark-1.2-contributor")).toBe( - true - ); - expect(updated.zen.some((m) => m.id === "muse-spark-1.2")).toBe(true); - }); - - it("refreshes from the OpenCode registry host, not a third-party mirror", async () => { - // Pinned because nothing else would notice a silent re-point: the two hosts - // serve the SAME object today (identical ETag), so a wrong URL still returns - // valid data — which is exactly why the assertion is on the request, not on - // the answer. - const seen: string[] = []; - const recordingFetch = vi.fn((input: unknown) => { - seen.push(String(input)); - return Promise.resolve({ - json: async () => ({ opencode: { models: {} } }), - ok: true, - }); - }); - - await refreshCatalog(recordingFetch as unknown as typeof fetch, true); - - expect(seen).toHaveLength(1); - expect(seen[0]).toBe("https://models.opencode.ai/api.json"); - expect(seen[0]).not.toContain("models.dev"); - }); - - it("resetCatalogState puts a refreshed catalog back to the bundled shim", async () => { - // The per-test reset in `test/support/` relies on this, and a reset that - // restored nothing would let every catalog count depend on run order again. - await refreshCatalog( - (async () => - Response.json({ - "opencode-go": { - models: { "reset-probe-model": { name: "Reset Probe" } }, - }, - })) as unknown as typeof fetch, - true - ); - expect(getLiveGoCatalog().some((m) => m.id === "reset-probe-model")).toBe( - true - ); - resetCatalogState(); - expect(getLiveGoCatalog().some((m) => m.id === "reset-probe-model")).toBe( - false - ); - expect(getLiveGoCatalog()).toHaveLength(OPENCODE_GO_CATALOG.length); - expect(getLiveZenCatalog()).toHaveLength(OPENCODE_ZEN_CATALOG.length); - }); - - it("refreshCatalog degrades gracefully on network errors without throwing", async () => { - const before = { - go: getLiveGoCatalog().length, - zen: getLiveZenCatalog().length, - }; - const mockFailingFetch = vi - .fn() - .mockRejectedValue(new Error("Network timeout")); - - const catalog = await refreshCatalog( - mockFailingFetch as unknown as typeof fetch, - true - ); - - // A failed refresh must resolve (never throw) and leave the active - // catalog exactly as it was, so the meter keeps working offline. - expect(catalog.go.length).toBe(before.go); - expect(catalog.zen.length).toBe(before.zen); - expect(catalog.go.length).toBeGreaterThanOrEqual( - OPENCODE_GO_CATALOG.length - ); - }); - it("sanitizeModalities restricts modalities strictly to text and image", () => { expect( sanitizeModalities(["text", "image", "video", "audio", "pdf"]) @@ -769,37 +341,43 @@ describe("OpenCode Model Catalog & Enrichment", () => { }); describe("findModelSpec", () => { - it("resolves a Go model together with its pricing rate", () => { + // Rows are picked by shape from the shipped shim and the lookup must answer + // that very row: a price or a context window the vendor changes moves both + // sides together, so a refresh is never a test edit. + it("resolves a priced Go model together with its pricing rate", () => { // The stream hook prices each turn through this lookup, so the rate must // ride along with the spec. - const spec = findModelSpec("deepseek-v4.1-flash"); - assert.ok(spec, "expected the Go shim to carry deepseek-v4.1-flash"); - expect(spec.id).toBe("deepseek-v4.1-flash"); - expect(spec.name).toBe("DeepSeek V4.1 Flash"); - expect(spec.context_window).toBe(1_000_000); - expect(spec.cost).toEqual({ cache_read: 0.003, input: 0.15, output: 0.6 }); - expect(spec.is_free).toBeUndefined(); + const row = OPENCODE_GO_CATALOG.find((m) => (m.cost?.input ?? 0) > 0); + assert.ok(row, "the Go shim carries no priced model"); + const spec = findModelSpec(row.id); + expect(spec).toEqual(row); + expect(spec?.cost?.input).toBeGreaterThan(0); + expect(spec?.is_free).toBeUndefined(); }); it("resolves a free Zen model, marked free at an explicit zero rate", () => { - const spec = findModelSpec("mimo-v2.6-flash-free"); - assert.ok(spec, "expected the Zen shim to carry mimo-v2.6-flash-free"); - expect(spec.is_free).toBe(true); - expect(spec.context_window).toBe(200_000); - expect(spec.max_output_tokens).toBe(32_000); + // A Zen-only row, so the plane-blind lookup (Go first) lands on Zen. + const go = new Set(OPENCODE_GO_CATALOG.map((m) => m.id)); + const row = OPENCODE_ZEN_CATALOG.find( + (m) => m.is_free === true && !go.has(m.id) + ); + assert.ok(row, "the Zen shim carries no free model of its own"); + const spec = findModelSpec(row.id); + expect(spec).toEqual(row); + expect(spec?.is_free).toBe(true); // A free model still carries an explicit zero rate, so the stream hook // prices it as $0 rather than falling back to "unknown model" handling. - expect(spec.cost).toEqual({ cache_read: 0, input: 0, output: 0 }); + expect(spec?.cost?.input).toBe(0); + expect(spec?.cost?.output).toBe(0); }); it("resolves a model present only on Go through the plane-blind lookup", () => { - // Provider scoping comes from the REGISTRY's data, not from a rule here: - // the paid 1.2 contributor entry is absent from the zen plane and served - // on go, and a plane-blind lookup finds it through go — which Go sessions + // A plane-blind lookup finds a Go-only row through go — which Go sessions // need for pricing a model the gateway still serves. - const spec = findModelSpec("muse-spark-1.2-contributor"); - assert.ok(spec, "expected the Go shim to keep muse-spark-1.2-contributor"); - expect(spec.id).toBe("muse-spark-1.2-contributor"); + const zen = new Set(OPENCODE_ZEN_CATALOG.map((m) => m.id)); + const row = OPENCODE_GO_CATALOG.find((m) => !zen.has(m.id)); + assert.ok(row, "the Go shim carries no model of its own"); + expect(findModelSpec(row.id)).toEqual(row); }); it("returns undefined for unknown or empty ids", () => { @@ -809,416 +387,3 @@ describe("findModelSpec", () => { expect(findModelSpec("DeepSeek-V4.1-Flash")).toBeUndefined(); }); }); - -describe("enrichModelsResponse: the gateway's verdict passes through", () => { - const url = "https://opencode.ai/zen/v1/models"; - - it("enrichModelsResponse: a 401, a 403, a 429 and a 5xx reach the caller as the gateway sent them", async () => { - // A refused key, a refused tier and an outage are answers about the - // REQUEST. Filling them with the catalog told discovery the listing had - // succeeded, and the reason the gateway gave never reached anyone. - const rows = [ - { - body: JSON.stringify({ error: { message: "Missing API key" } }), - status: 401, - type: "application/json", - }, - { - body: JSON.stringify({ error: { type: "FreeTierError" } }), - status: 403, - type: "application/json", - }, - // Not an auth answer and not an outage: the rule is "every non-2xx but - // 404", and this row records that a rate limit is the gateway's too. - { - body: JSON.stringify({ error: { type: "RateLimitError" } }), - headers: { "retry-after": "30" }, - status: 429, - type: "application/json", - }, - { body: "upstream down", status: 503, type: "text/plain" }, - ]; - for (const row of rows) { - const extra: Record = - "headers" in row && row.headers !== undefined ? row.headers : {}; - const upstream = new Response(row.body, { - headers: { "content-type": row.type, ...extra }, - status: row.status, - }); - const out = await enrichModelsResponse(url, upstream); - expect(out.status).toBe(row.status); - expect(await out.text()).toBe(row.body); - expect(out.headers.get("content-type")).toBe(row.type); - for (const [name, value] of Object.entries(extra)) { - expect(out.headers.get(name)).toBe(value); - } - } - }); - - it("fills a Zen-plane 404 with the Zen catalog, as it does the Go plane's", async () => { - const out = await enrichModelsResponse( - url, - new Response("Not Found", { status: 404 }) - ); - expect(out.status).toBe(200); - const body = (await out.json()) as { data: unknown[] }; - expect(body.data.length).toBe(getLiveZenCatalog().length); - }); - - it("still answers a 200 whose body is not JSON with the full catalog", async () => { - const out = await enrichModelsResponse( - url, - new Response("not json", { status: 200 }) - ); - expect(out.status).toBe(200); - const body = (await out.json()) as { data: unknown[] }; - expect(body.data.length).toBe(getLiveZenCatalog().length); - }); -}); - -describe("enrichModelsResponse: a model neither source describes", () => { - it("still describes it fully, so the picker never shows a row with no limits", async () => { - // The gateway adds models before models.dev — or the bundled shim — knows - // them. DSH sizes its context meter from these numbers, and the live e2e - // asserts every row the picker sees carries them; an `undefined` here - // reaches the UI as a model with no limits at all. - const url = "https://opencode.ai/zen/v1/models"; - const upstream = Response.json({ - data: [{ id: "brand-new-model-nobody-knows", object: "model" }], - }); - - const response = await enrichModelsResponse(url, upstream); - const body = (await response.json()) as { data: Record[] }; - const row = body.data.find( - (entry) => entry.id === "brand-new-model-nobody-knows" - ); - expect(row).toBeDefined(); - expect(typeof row?.context_window).toBe("number"); - expect(typeof row?.max_output_tokens).toBe("number"); - expect(row?.context_window).toBeGreaterThan(0); - expect(row?.max_output_tokens).toBeGreaterThan(0); - }); -}); - -/** - * Every SDK the generated catalog names must be CLASSIFIED - either mapped to a - * protocol or listed as deliberately unserved. The failure this prevents is - * silent: models.dev gains a new SDK, the freshness gate regenerates the shim - * and passes, and the model disappears from the picker because the servability - * check treats "unknown" as "unservable". - */ -/** - * The README's routing table, asserted against the catalog it describes. - * - * A count written into prose does not regenerate with the data it counts, and - * this file's history is the evidence: the table said 28 models named no SDK and - * had no row for mistral at all, a regeneration after it was written. Nothing - * failed, because nothing was checking - and a user reading the table would have - * been told the wrong thing about the catalog they were choosing from. - * - * So the table is parsed and compared. When the catalog is regenerated with - * different counts, this goes red and says which numbers to update, instead of - * the prose drifting quietly out of date. - */ -describe("catalog · the README's routing table", () => { - const readme = () => - readFile(new URL("../README.md", import.meta.url), "utf8"); - - /** `| \`@ai-sdk/x\` | 12 | ... |` and `| _(absent)_ | 12 | ... |`, in order. */ - const documentedCounts = (text: string): Map => { - const counts = new Map(); - for (const line of text.split("\n")) { - const match = /^\|\s*(?:`([^`]+)`|_\(absent\)_)\s*\|\s*(\d+)\s*\|/u.exec( - line - ); - if (match !== null) { - counts.set(match[1] ?? "(absent)", Number(match[2])); - } - } - return counts; - }; - - it("matches the catalog it counts", async () => { - const measured = new Map(); - for (const model of Object.values(OPENCODE_ZEN_CATALOG)) { - const sdk = model.provider_npm ?? "(absent)"; - measured.set(sdk, (measured.get(sdk) ?? 0) + 1); - } - - // The table has to describe every SDK the catalog names - a row that is - // missing is the failure the mistral row was. - expect(documentedCounts(await readme())).toEqual(measured); - }); - - it("holds the protocol doc's table to the same catalog", async () => { - // The same class of claim in a second document: "28 of 84" is a count - // written into prose, and it drifts exactly the way the README's did. The - // numbers are parsed rather than trusted, and the total has to match too. - const text = await readFile( - new URL("../docs/protocol-routing-and-merge.md", import.meta.url), - "utf8" - ); - const pairs = [...text.matchAll(/(\d+) of (\d+)/gu)].map((m) => [ - Number(m[1] ?? 0), - Number(m[2] ?? 0), - ]); - const total = Object.keys(OPENCODE_ZEN_CATALOG).length; - const measured = new Map(); - for (const model of Object.values(OPENCODE_ZEN_CATALOG)) { - const sdk = model.provider_npm ?? "(absent)"; - measured.set(sdk, (measured.get(sdk) ?? 0) + 1); - } - - expect(pairs.length).toBeGreaterThan(0); - for (const [count, of] of pairs) { - expect(of, "a row states a total the catalog does not have").toBe(total); - expect( - [...measured.values()], - `${count} of ${of} matches no SDK count` - ).toContain(count); - } - // And the rows add up, so the table cannot disagree with itself. - expect(pairs.reduce((sum, pair) => sum + (pair[0] ?? 0), 0)).toBe(total); - }); - - it("holds the prose totals to the catalog, in both languages", async () => { - // The tables above are parsed; the totals in the sentences beside them were - // not, and every count this repository has had to correct lived there - the - // README said 84 for a catalog of 85, the plane table said 120 served where - // 85 are, and the Chinese README carried both. A number in a sentence is the - // easiest kind to forget and the one a reader trusts most, so it is parsed - // too. The regex allows the emphasis markers the number is wrapped in. - const zen = Object.keys(OPENCODE_ZEN_CATALOG).length; - const go = Object.keys(OPENCODE_GO_CATALOG).length; - for (const file of ["../README.md", "../README.zh-CN.md"]) { - const text = await readFile(new URL(file, import.meta.url), "utf8"); - const stated = [ - ...text.matchAll( - /(\d+)\*{0,2}\s*(?:active[^.]*models|个\*\*在用\*\*模型)/gu - ), - ].map((m) => Number(m[1] ?? 0)); - expect( - stated.length, - `${file} states the catalog total nowhere` - ).toBeGreaterThan(0); - for (const n of stated) { - expect(n, `${file} states a Zen total the catalog does not have`).toBe( - zen - ); - } - } - // "85 served / 120 listed" and "32 served / 36 listed": the served half is - // ours to assert, the listed half comes from models.dev and is not. - const planes = await readFile( - new URL("../docs/redirect-planes.md", import.meta.url), - "utf8" - ); - const served = [...planes.matchAll(/(\d+)\s*served\s*\//gu)].map((m) => - Number(m[1] ?? 0) - ); - expect(served).toEqual([zen, go]); - }); - - it("holds the plane-by-plane table to both catalogs", async () => { - // The third document with counts, and the one whose table is per plane: - // every cell is a count, and every cell was stale when this was written. - const text = await readFile( - new URL("../docs/redirect-planes.md", import.meta.url), - "utf8" - ); - const counted = (catalog: readonly { provider_npm?: string }[]) => { - const bySdk = new Map(); - for (const model of Object.values(catalog)) { - const sdk = model.provider_npm ?? "(none)"; - bySdk.set(sdk, (bySdk.get(sdk) ?? 0) + 1); - } - return bySdk; - }; - const zen = counted(OPENCODE_ZEN_CATALOG); - const go = counted(OPENCODE_GO_CATALOG); - - const rows: { go: number; sdk: string; zen: number }[] = []; - for (const line of text.split("\n")) { - const cells = line.split("|").map((cell) => cell.trim()); - // `| SDK | protocol | Zen | Go |` — five cells once split on the pipes. - if (cells.length !== 6 || cells[1] === "" || cells[1] === ":--") { - continue; - } - const sdk = - cells[1] === "_(none)_" - ? "(none)" - : /^`([^`]+)`$/u.exec(cells[1] ?? "")?.[1]; - const zenCount = Number(cells[3]?.replaceAll("*", "")); - const rawGo = cells[4]?.replaceAll("*", "") ?? ""; - const goCount = rawGo === "—" || rawGo === "-" ? 0 : Number(rawGo); - if ( - sdk === undefined || - !Number.isFinite(zenCount) || - !Number.isFinite(goCount) - ) { - continue; - } - rows.push({ go: goCount, sdk, zen: zenCount }); - } - - expect(rows.length).toBeGreaterThan(0); - for (const row of rows) { - expect(row.zen, `${row.sdk} on Zen`).toBe(zen.get(row.sdk) ?? 0); - expect(row.go, `${row.sdk} on Go`).toBe(go.get(row.sdk) ?? 0); - } - // Every SDK either plane names has a row, so a new one cannot be missed. - expect(rows.map((row) => row.sdk).toSorted()).toEqual( - [...new Set([...zen.keys(), ...go.keys()])].toSorted() - ); - }); - - it("states the same total it lists", async () => { - const text = await readme(); - const total = /Counts are the \*\*(\d+)\*\*/u.exec(text); - expect(total, "the README no longer states a total").not.toBeNull(); - expect(Number(total?.[1])).toBe(Object.keys(OPENCODE_ZEN_CATALOG).length); - // And the rows add up to it, so the table cannot disagree with its own - // sentence the way it did before this test existed. - const sum = [...documentedCounts(text).values()].reduce((a, b) => a + b, 0); - expect(sum).toBe(Number(total?.[1])); - }); -}); - -describe("catalog · SDK coverage", () => { - it("classifies every provider SDK the catalog names", () => { - const named = new Set(); - for (const plane of [OPENCODE_ZEN_CATALOG, OPENCODE_GO_CATALOG]) { - for (const model of Object.values(plane)) { - const sdk = (model as { provider_npm?: string }).provider_npm; - if (typeof sdk === "string" && sdk.length > 0) { - named.add(sdk); - } - } - } - // Non-vacuous: the catalogs name more than one SDK. - expect(named.size).toBeGreaterThan(1); - const classified = new Set(Object.keys(PROTOCOL_FOR_SDK)); - expect([...named].filter((sdk) => !classified.has(sdk))).toEqual([]); - expect([...named].filter((sdk) => !isServableSdk(sdk))).toEqual([]); - }); -}); - -/** A plane's model as the plane adapter would build it, on the default family. */ -const servedOn = ( - route: "opencode" | "opencode-go", - id: string, - routeApi?: string -) => { - const catalog = - route === "opencode" ? OPENCODE_ZEN_CATALOG : OPENCODE_GO_CATALOG; - const spec = catalog.find((candidate) => candidate.id === id); - const result = planeModelFor({ - defaults: { contextWindow: 262_144, input: ["text"], maxTokens: 32_768 }, - endpoint: "modern", - entry: { id }, - familyApi: - route === "opencode" ? OPENCODE_FAMILY_API.zen : OPENCODE_FAMILY_API.go, - route, - ...(routeApi === undefined ? {} : { routeApi }), - ...(spec === undefined ? {} : { spec }), - }); - if (result.model === undefined) { - throw new Error(`${route}/${id}: ${result.error}`); - } - return result.model; -}; - -/** - * INVARIANT I1 - every model the plugin offers is routable. - * - * The picker shows both planes' models. A model naming an SDK is served on that - * SDK's protocol, on ITS OWN plane: the Go plane's base URL is not Zen's. One - * route per plane carries every protocol (D13), so the check is that each - * catalog model resolves to a protocol and to a base on its own plane. - */ -describe("catalog · plane and SDK coverage", () => { - it("serves every catalog model on its own plane's base, whatever its SDK", () => { - const gaps: string[] = []; - const planes = [ - ["opencode", OPENCODE_ZEN_CATALOG], - ["opencode-go", OPENCODE_GO_CATALOG], - ] as const; - let checked = 0; - for (const [plane, catalog] of planes) { - for (const spec of catalog) { - const model = servedOn(plane, spec.id); - checked += 1; - const onGo = new URL(model.baseUrl).pathname.startsWith( - "/inference/go/" - ); - if (model.api !== protocolForSdk(spec.provider_npm)) { - gaps.push(`${plane}/${spec.id}: ${model.api}`); - } - if (onGo !== (plane === "opencode-go")) { - gaps.push(`${plane}/${spec.id}: ${model.baseUrl}`); - } - } - } - expect(checked).toBe( - OPENCODE_ZEN_CATALOG.length + OPENCODE_GO_CATALOG.length - ); - expect(gaps).toEqual([]); - }); -}); - -/** - * The plane is half of the routing decision. An SDK says which shape a model - * needs; only the route the caller selected says which account and base URL - * serve it. - */ -describe("routing · the plane half of the decision", () => { - it("sends the same SDK to a different base per plane", () => { - expect(servedOn("opencode", "gpt-5.4")).toMatchObject({ - api: "openai-responses", - baseUrl: "https://opencode.ai/inference/openai/v1", - }); - expect(servedOn("opencode-go", "grok-4.7")).toMatchObject({ - api: "openai-responses", - baseUrl: "https://opencode.ai/inference/go/openai/v1", - }); - expect(servedOn("opencode-go", "minimax-m3")).toMatchObject({ - api: "anthropic-messages", - baseUrl: "https://opencode.ai/inference/go/anthropic", - }); - expect(servedOn("opencode-go", "gemini-4-argon-contributor")).toMatchObject( - { - api: "google-generative-ai", - baseUrl: "https://opencode.ai/inference/go/google/v1beta", - } - ); - }); -}); - -/** - * The default format. A model that speaks completions has no SDK of its own, - * and an id nobody catalogued is served the same way (D15): a name is not a - * protocol, so `claude-unlisted-9` is not guessed onto Messages. - */ -describe("routing · the default format", () => { - it("serves a model with no SDK of its own as completions", () => { - for (const route of ["opencode", "opencode-go"] as const) { - expect(servedOn(route, "big-pickle").api).toBe("openai-completions"); - } - }); - - it("serves an id the catalog does not describe as completions, unless the route names a protocol", () => { - expect(servedOn("opencode", "claude-unlisted-9").api).toBe( - "openai-completions" - ); - expect(servedOn("opencode", "gemini-unlisted-9").api).toBe( - "openai-completions" - ); - expect( - servedOn("opencode", "claude-unlisted-9", "anthropic-messages").api - ).toBe("anthropic-messages"); - // A route-level api never reclassifies an id the catalog describes. - expect(servedOn("opencode", "gpt-5.4", "anthropic-messages").api).toBe( - "openai-responses" - ); - }); -}); diff --git a/test/client-headers-affinity.test.ts b/test/client-headers-affinity.test.ts deleted file mode 100644 index 63d53133..00000000 --- a/test/client-headers-affinity.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Test: Client Headers, Session Affinity, and Lineage Contracts. - * - * Validates: - * - 1.19.4 generation contracts: x-opencode-session-id and x-opencode-session, no affinity on OpenCode planes - * - Relay providers: sends x-session-affinity and x-session-id pair - * - Subagent parent session lineage: x-opencode-parent-session-id and x-parent-session-id - * - Session header recovery: patchFetch automatically derives a valid ses_<...> ID when missing - * - User-Agent customization and injectUserAgent toggle - * - Origin headers: x-opencode-client, x-opencode-project, and the turn's - * x-opencode-request message id - * - * @module test/client-headers-affinity.test - */ - -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import { - ClientSimulator, - GatewayTestRunner, - MOCK_SESSION, - ZEN_KEY, -} from "./mock/testing-kit.ts"; -import { - PARENT_SESSION_ALT_HEADER, - PARENT_SESSION_HEADER, - SESSION_AFFINITY_HEADER, - SESSION_HEADER, - clearCapturedApiKeys, -} from "./support/src.ts"; - -let runner: GatewayTestRunner; - -beforeAll(async () => { - runner = new GatewayTestRunner(); - await runner.start(); -}); - -afterAll(async () => { - await runner.close(); -}); - -beforeEach(() => { - runner.clearRequests(); - clearCapturedApiKeys(); - vi.stubEnv("OPENCODE_API_KEY", ZEN_KEY); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -describe("Client Headers, Session Affinity & Lineage", () => { - it("injects 1.19.4 generation headers on OpenCode planes without affinity headers", async () => { - const client = new ClientSimulator({ - generation: "1.19.4", - project: "dsh-affinity-test", - sessionId: "ses_eb1e9e706ffeCDEFGHIJKLMNOP", - }); - - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - clientSimulator: client, - }); - - expect(result.status).toBe(200); - const req = result.request!; - expect(req.headers["x-opencode-session-id"]).toBe(client.sessionId); - expect(req.headers[SESSION_HEADER]).toBe(client.sessionId); - expect(req.headers[SESSION_AFFINITY_HEADER]).toBeUndefined(); - expect(req.headers["x-session-id"]).toBeUndefined(); - }); - - it("injects affinity pair for relay / non-OpenCode providers", async () => { - const client = new ClientSimulator({ - generation: "1.19.4", - sessionId: "ses_eb1e9e706fferalay0000000000", - }); - - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - turnState: client.asTurnState({ provider: "relay-deepseek" }), - }); - - expect(result.status).toBe(200); - const req = result.request!; - expect(req.headers[SESSION_AFFINITY_HEADER]).toBe(client.sessionId); - expect(req.headers["x-session-id"]).toBe(client.sessionId); - }); - - it("propagates parent lineage headers for subagents and child sessions", async () => { - const parentId = "ses_eb1e9e706ffeparent12345678"; - const client = new ClientSimulator({ - parentSessionId: parentId, - sessionId: "ses_eb1e9e706ffchild0123456789", - }); - - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - clientSimulator: client, - }); - - expect(result.status).toBe(200); - const req = result.request!; - expect(req.headers[PARENT_SESSION_HEADER]).toBe(parentId); - expect(req.headers[PARENT_SESSION_ALT_HEADER]).toBe(parentId); - }); - - it("recovers missing session header automatically when request goes through patchFetch", async () => { - const client = new ClientSimulator({ sessionId: "" }); - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - clientSimulator: client, - turnState: null, - }); - - expect(result.status).toBe(200); - expect(result.request?.headers["x-opencode-session-id"]).toMatch(/^ses_/); - }); - - it("respects custom User-Agent and preserves existing User-Agent when disabled", async () => { - // When injectUserAgent is false, existing User-Agent is preserved - const preservedUa = await runner.execute("opencode", "/zen/v1/responses", { - authHeaders: { "user-agent": "upstream-caller/1.0" }, - body: { input: [], model: "gpt-5.4", stream: true }, - config: { injectUserAgent: false }, - }); - expect(preservedUa.request?.headers["user-agent"]).toBe( - "upstream-caller/1.0" - ); - - // Custom user-agent override - const customUa = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - config: { userAgent: "CustomOpenCodeIDE/4.0.0" }, - }); - expect(customUa.request?.headers["user-agent"]).toBe( - "CustomOpenCodeIDE/4.0.0" - ); - }); - - it("configures origin headers and project dynamically", async () => { - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - config: { - injectOriginHeaders: true, - injectProject: true, - originClient: "web", - }, - turnState: { - model: "gpt-5.4", - project: "my-project-repo", - provider: "opencode", - requestValue: "msg_0123456789abCDEFGHIJKLMNOP", - value: MOCK_SESSION, - }, - }); - - expect(result.status).toBe(200); - const req = result.request!; - expect(req.headers["x-opencode-client"]).toBe("web"); - expect(req.headers["x-opencode-project"]).toBe("my-project-repo"); - // The turn's message id travels with the origin headers, as in 1.19.4's - // `request.ts`, where `x-opencode-request` sits in the same branch as the - // client and project headers. - expect(req.headers["x-opencode-request"]).toBe( - "msg_0123456789abCDEFGHIJKLMNOP" - ); - }); - - it("omits origin headers when injectOriginHeaders is false", async () => { - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - config: { injectOriginHeaders: false }, - turnState: { - model: "gpt-5.4", - provider: "opencode", - requestValue: "msg_0123456789abCDEFGHIJKLMNOP", - value: MOCK_SESSION, - }, - }); - - expect(result.status).toBe(200); - const req = result.request!; - expect(req.headers["x-opencode-client"]).toBeUndefined(); - expect(req.headers["x-opencode-project"]).toBeUndefined(); - // The message id belongs to the same switch, and the turn HAS one here — - // the absence below is the switch, not a missing value. - expect(req.headers["x-opencode-request"]).toBeUndefined(); - }); - - it("omits x-opencode-request when the turn carries no message id", async () => { - // Outside any tracked turn — the usage and discovery calls — there is no - // message to name, and an absent header is correct. An empty - // `x-opencode-request:` is a value the client cannot send. - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - turnState: { - model: "gpt-5.4", - provider: "opencode", - value: MOCK_SESSION, - }, - }); - - expect(result.status).toBe(200); - expect(result.request?.headers["x-opencode-request"]).toBeUndefined(); - }); -}); diff --git a/test/composition.test.ts b/test/composition.test.ts index 6fb9f5a1..d4bb079e 100644 --- a/test/composition.test.ts +++ b/test/composition.test.ts @@ -16,22 +16,22 @@ * **Why a real `Context` and not an object literal.** Their plugins use * `new Context()` because `@deepseek-ai/cordis` is a peer dependency in every one * of their 312 packages. It is one here too now, so this test can mount the - * plugin the way the Loader does — `ctx.plugin({ inject, apply })` — which means + * plugin the way the Loader does — `ctx.plugin({ apply, Config, inject, name })` + * with the row config — which means * cordis itself decides whether the plugin activates. A missing or wrong * declaration then fails HERE, in the suite, instead of throwing in a deployment * as `cannot get property "loader" without inject`. * * @module test/composition.test */ -import { Context } from "@deepseek-ai/cordis"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; -import { - type CordisContext, - apply, - inject, - inspectPatch, -} from "./support/src.ts"; +import { disposeMounted, mountPlugin, until } from "./support/composition.ts"; +import { inject, inspectPatch } from "./support/src.ts"; + +afterEach(async () => { + await disposeMounted(); +}); /** The `llm` service, shaped as the plugin reads it, with its own discovery. */ const llmService = () => ({ @@ -41,38 +41,9 @@ const llmService = () => ({ registerAdapter: () => () => {}, }); -/** - * Mount the plugin the way the Loader does. - * - * `inject` is the plugin's OWN declaration — empty, so the root activates - * before `llm` exists and everything that needs `llm` waits in a child scope — - * and cordis itself decides when each part runs, which a hand-built context - * could not. - */ -const mount = async ( - options: { - readonly withLlm?: boolean; - readonly config?: Record; - } = {} -) => { - const ctx = new Context(); - if (options.withLlm !== false) { - ctx.provide("llm", llmService() as never); - } - let activated = false; - const fiber = ctx.plugin({ - inject, - apply: (scope: unknown) => { - activated = true; - return apply(scope as CordisContext, options.config ?? {}); - }, - }); - await fiber.await?.(); - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - return { activated, ctx }; -}; +/** Mount the plugin with an `llm` service, the composition's usual shape. */ +const mount = (config: Record = {}) => + mountPlugin({ config, llm: llmService() }); describe("composition · a real cordis Context", () => { it("activates without `llm`, and what needs `llm` waits in a child scope until it is provided", async () => { @@ -83,17 +54,17 @@ describe("composition · a real cordis Context", () => { expect(inject).toEqual([]); const service = llmService(); const original = service.discoverModels; - const without = await mount({ withLlm: false }); - expect(without.activated, "the root waits for nothing").toBe(true); + const without = await mountPlugin(); + expect(without.activated(), "the root waits for nothing").toBe(true); without.ctx.provide("llm", service as never); - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); + const wrapped = (): unknown => + Reflect.getOwnPropertyDescriptor(service, "discoverModels")?.value; + await until( + () => wrapped() !== original, + "the llm scope wraps Settings' discovery" + ); // The `llm` scope ran: Settings' discovery is wrapped on the service. - const installed = Reflect.getOwnPropertyDescriptor( - service, - "discoverModels" - )?.value; + const installed = wrapped(); expect(installed).not.toBe(original); expect(inspectPatch(installed, "discoverModels")).toEqual([ expect.objectContaining({ @@ -107,7 +78,7 @@ describe("composition · a real cordis Context", () => { // The exact failure that shipped: the service mounted, the namespace was // never contributed, and the row said "unavailable" forever. Here the // question is the consumer's, through the same access path the host uses. - const { ctx } = await mount({ config: { usageEnabled: false } }); + const { ctx } = await mount({ usageEnabled: false }); const purge = ctx.get("opencodeModelPurge") as | { purge?: unknown } | undefined; @@ -120,17 +91,17 @@ describe("composition · a real cordis Context", () => { // "check this machine" button resolves the namespace at click time, and a // service that mounted under the wrong name answers `undefined` there — which // the card can only report as "this host mounted no inspection service". - const { ctx } = await mount({ config: { usageEnabled: false } }); + const { ctx } = await mount({ usageEnabled: false }); const store = ctx.get("opencodeStore") as { inspect?: unknown } | undefined; expect(store, "the inspection service must be reachable").toBeDefined(); expect(typeof store?.inspect).toBe("function"); }); it("leaves the usage service reachable only while the meter is on", async () => { - const off = await mount({ config: { usageEnabled: false } }); + const off = await mount({ usageEnabled: false }); expect(off.ctx.get("opencodeGoUsage")).toBeUndefined(); - const on = await mount({ config: { usageEnabled: true } }); + const on = await mount({ usageEnabled: true }); const usage = on.ctx.get("opencodeGoUsage") as | { read?: unknown } | undefined; @@ -142,8 +113,8 @@ describe("composition · a real cordis Context", () => { // A composition without the settings service is a real deployment: the // card's purge row reports unavailability rather than the plugin failing to // load. Proven by mounting, not by reading a branch. - const { activated, ctx } = await mount({ config: { usageEnabled: false } }); - expect(activated).toBe(true); + const { activated, ctx } = await mount({ usageEnabled: false }); + expect(activated()).toBe(true); expect(ctx.get("opencodeModelPurge")).toBeDefined(); }); }); diff --git a/test/dispatch-levels.test.ts b/test/dispatch-levels.test.ts new file mode 100644 index 00000000..772a8c40 --- /dev/null +++ b/test/dispatch-levels.test.ts @@ -0,0 +1,176 @@ +/** + * Dispatch against the real rule: every level the picker offers is accepted. + * + * Two readers decide whether a turn's saved thinking level survives. The + * composer renders the Effort row the plane adapter answers, and dispatch + * validates the saved effort with `getSupportedThinkingLevels` against the + * model object the adapter built. Before P-R1 those were two decorators over + * pi-ai's adapter, and when they disagreed `glm-5.3-flash-free` offered `high` + * and `max` beside its name in the composer and answered + * UNSUPPORTED_REASONING_EFFORT for both. Now both come from one rule + * (`reasoningFor`, D1) folded into one model object (`planeModelFor`), so + * this file is the check that the one rule and pi's agree. + * + * `test/model-metadata.test.ts` pins the rule against pi's levels for the + * rule's own map. This file imports pi's rule ITSELF — + * `getSupportedThinkingLevels` from the installed `@earendil-works/pi-ai`, the + * function `dsh-llm-pi-ai` carries its own copy of and validates with — and + * sweeps the whole shipped catalog on both planes through the model object + * the plane adapter serves: for every model, **the levels the picker offers + * are exactly the levels the rule accepts**. It needs no network and no port, + * so it runs in the unit suite; the live, keyed half is + * `test/e2e/dispatch-levels.e2e.ts`. + * + * @module test/dispatch-levels.test + */ + +import { readFile } from "node:fs/promises"; + +import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/models"; +import { describe, expect, it } from "vitest"; + +import { + type CatalogModelSpec, + OPENCODE_FAMILY_API, + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, + planeModelFor, + type Protocol, + protocolForSdk, + reasoningFor, +} from "./support/src.ts"; + +/** The two plane routes, and the catalog and family map each resolves from. */ +const PLANES = [ + { + catalog: OPENCODE_ZEN_CATALOG, + family: OPENCODE_FAMILY_API.zen, + route: "opencode", + }, + { + catalog: OPENCODE_GO_CATALOG, + family: OPENCODE_FAMILY_API.go, + route: "opencode-go", + }, +] as const; + +/** The model the plane adapter serves for one catalog row, on the default family. */ +const servedModel = ( + plane: (typeof PLANES)[number], + spec: CatalogModelSpec +): Readonly> & { api: Protocol; baseUrl: string } => { + const { model, error } = planeModelFor({ + defaults: { contextWindow: 262_144, input: ["text"], maxTokens: 32_768 }, + endpoint: "modern", + entry: { id: spec.id }, + familyApi: plane.family, + route: plane.route, + spec, + }); + if (model === undefined) { + throw new Error(`${plane.route}/${spec.id}: ${error}`); + } + return model; +}; + +/** The levels the picker offers for a served model, as effort ids. */ +const offeredLevels = ( + spec: CatalogModelSpec, + model: Readonly> & { api: Protocol } +): string[] => + reasoningFor( + spec, + model.api, + model.compat as Readonly> | undefined + )?.efforts.map((effort) => effort.id) ?? ["off"]; + +/** + * The real rule. `getSupportedThinkingLevels` is typed against pi's `Model` + * union; the served model is the shape it reads two fields off, so the cast + * is the seam. + */ +const acceptedLevels = (model: unknown): string[] => + getSupportedThinkingLevels(model as never); + +describe("the levels the picker offers are the levels dispatch accepts", () => { + it("pins the deployed copy of the rule to the oracle it imports", async () => { + // `dsh-llm-pi-ai` carries its own copy of the rule — that copy is what + // the deployment validates with — and the oracle above imports the + // original from `@earendil-works/pi-ai`. The two are one rule by + // construction; this holds them together, so an upstream move fails here + // rather than quietly validating against the wrong thing. + const raw = await readFile( + new URL( + "../node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js", + import.meta.url + ), + "utf8" + ); + const source = raw.replaceAll("!== void 0", "!== undefined"); + expect( + source, + "the deployed rule's reasoning-false branch left the bundle" + ).toContain('if (!model.reasoning) return ["off"];'); + expect( + source, + "the deployed rule's xhigh/max requirement left the bundle" + ).toContain( + 'if (level === "xhigh" || level === "max") return mapped !== undefined;' + ); + }); + + it("sweeps the whole catalog: the offered levels are exactly the accepted ones", () => { + const problems: string[] = []; + for (const plane of PLANES) { + let checked = 0; + for (const spec of plane.catalog) { + const model = servedModel(plane, spec); + // The catalog's protocol is the one dispatched on (D16). + expect(model.api).toBe(protocolForSdk(spec.provider_npm)); + const offered = offeredLevels(spec, model); + const accepted = acceptedLevels(model); + if (offered.join(",") !== accepted.join(",")) { + problems.push( + `${plane.route} ${spec.id}: offers [${offered.join(", ")}], dispatch takes [${accepted.join(", ")}]` + ); + } + if (spec.reasoning === true) { + checked += 1; + } + } + // Anti-vacuity: a plane whose rows stopped reasoning (a parser change, + // a catalog swap) must fail here, not pass by covering zero. + expect(checked, `no ${plane.route} model reasons`).toBeGreaterThan(0); + } + expect(problems).toEqual([]); + }); + + it("a free Zen model offering several levels exists, and dispatch accepts every one (the shape of the reported bug)", () => { + // `glm-5.3-flash-free` offered levels its dispatch refused: a free Zen + // model offering more than one level. One rule now builds both, so + // whatever it offers is taken. Nothing is dispatched here: the sweep + // above compares every row, and this case guards it against the one + // vacuity it cannot see, a catalog with no free Zen model offering more + // than one level. The row is picked by that SHAPE — the + // reporting id while the catalog still carries it, else the first free + // Zen row offering more than one level — so a refresh that retires the + // id keeps the case meaning the same thing rather than failing it. + const [zen] = PLANES; + const offersSeveral = (spec: CatalogModelSpec): boolean => + spec.is_free === true && + offeredLevels(spec, servedModel(zen, spec)).length > 1; + const spec = + zen.catalog.find( + (row) => row.id === "glm-5.3-flash-free" && offersSeveral(row) + ) ?? zen.catalog.find(offersSeveral); + if (spec === undefined) { + throw new TypeError( + "no free Zen model offers more than one level: the reported shape is gone from the catalog" + ); + } + const model = servedModel(zen, spec); + const offered = offeredLevels(spec, model); + expect(offered.length).toBeGreaterThan(1); + expect(acceptedLevels(model)).toEqual(offered); + }); +}); diff --git a/test/e2e-env.test.ts b/test/e2e-env.test.ts new file mode 100644 index 00000000..0e570926 --- /dev/null +++ b/test/e2e-env.test.ts @@ -0,0 +1,126 @@ +/** + * The live suites' environment reader (`support/e2e-env.ts`), offline. + * + * `readE2eEnvironment` is the one place that decides whether a live suite runs + * at all, whether the published-host binary e2e runs, and which key each plane + * arms. A misread there is silent: a gate that reads the wrong variable skips + * the whole suite green, and a swapped plane sends one plane's key to the + * other's probes. Every case hands it an environment of its own, so nothing + * here reads the process's. + * + * @module test/e2e-env.test + */ +import { describe, expect, it } from "vitest"; + +import { readE2eEnvironment } from "./support/e2e-env.ts"; +import { KEYED_TIERS } from "./support/keyed-probes.ts"; + +/** Two keys no plane could confuse. */ +const KEYS = { + OPENCODE_API_KEY: "sk-zen-only", + OPENCODE_GO_API_KEY: "sk-go-only", +} as const; + +describe("readE2eEnvironment", () => { + it.for([ + ["1", true], + ["true", false], + ["0", false], + ["", false], + [undefined, false], + ] as const)("OPENCODE_E2E=%s runs the live suite: %s", ([value, live]) => { + expect(readE2eEnvironment({ OPENCODE_E2E: value }).live).toBe(live); + }); + + it("maps each plane to its own key, for every tier and need", () => { + for (const tier of KEYED_TIERS) { + const env = readE2eEnvironment({ ...KEYS, OPENCODE_E2E_KEYED: tier }); + expect(env.tier).toBe(tier); + for (const need of ["full", "probes"] as const) { + const armed = + tier === "full" || (tier === "probes" && need === "probes"); + expect(env.keyFor("opencode", need), `${tier}/${need}`).toBe( + armed ? KEYS.OPENCODE_API_KEY : undefined + ); + expect(env.keyFor("opencode-go", need), `${tier}/${need}`).toBe( + armed ? KEYS.OPENCODE_GO_API_KEY : undefined + ); + } + } + }); + + it("arms nothing for a plane whose key is unset or blank", () => { + const env = readE2eEnvironment({ + OPENCODE_API_KEY: "", + OPENCODE_E2E_KEYED: "full", + }); + expect(env.keyFor("opencode", "full")).toBeUndefined(); + expect(env.keyFor("opencode-go", "full")).toBeUndefined(); + }); + + it("an unset tier is full, and one it does not know fails the read", () => { + expect(readE2eEnvironment({}).tier).toBe("full"); + expect(() => + readE2eEnvironment({ OPENCODE_E2E_KEYED: "everything" }) + ).toThrow(/OPENCODE_E2E_KEYED must be one of/u); + }); + + it("a blank base URL is neither a mirror nor an override", () => { + const blank = readE2eEnvironment({ + OPENCODE_GO_BASE_URL: "", + OPENCODE_ZEN_BASE_URL: "", + }); + expect(blank.mirrored).toBe(false); + expect(blank.baseOverride).toEqual({}); + + const zenOnly = readE2eEnvironment({ + OPENCODE_ZEN_BASE_URL: "https://mirror.example/zen/v1", + }); + expect(zenOnly.mirrored).toBe(true); + expect(zenOnly.baseOverride).toEqual({ + zen: "https://mirror.example/zen/v1", + }); + }); + + it("the binary e2e runs only on DSH_HOST_E2E=1, with the pinned cell by default", () => { + const off = readE2eEnvironment({ DSH_HOST_E2E: "true" }); + expect(off.hostBinary).toEqual({ + cell: "pinned", + enabled: false, + path: "", + prefix: "", + probeCopy: "", + }); + + const on = readE2eEnvironment({ + DSH_HOST_E2E: "1", + DSH_HOST_PI_CELL: "floating", + DSH_HOST_PREFIX: "/opt/dsh", + DSH_HOST_PROBE_COPY: "/opt/dsh/probe.json", + PATH: "/opt/dsh/bin", + }); + expect(on.hostBinary).toEqual({ + cell: "floating", + enabled: true, + path: "/opt/dsh/bin", + prefix: "/opt/dsh", + probeCopy: "/opt/dsh/probe.json", + }); + }); + + it("carries the record directory and the CI run a record links to", () => { + const env = readE2eEnvironment({ + GITHUB_REPOSITORY: "owner/repo", + GITHUB_RUN_ID: "42", + GITHUB_SERVER_URL: "https://github.example", + OPENCODE_E2E_RECORD_DIR: "records", + }); + expect(env.recordDir).toBe("records"); + expect(env.run).toEqual({ + repository: "owner/repo", + runId: "42", + server: "https://github.example", + }); + expect(readE2eEnvironment({}).recordDir).toBe(""); + }); +}); diff --git a/test/e2e/dispatch-levels.e2e.ts b/test/e2e/dispatch-levels.e2e.ts index 8c7ed467..ad7a261f 100644 --- a/test/e2e/dispatch-levels.e2e.ts +++ b/test/e2e/dispatch-levels.e2e.ts @@ -1,5 +1,6 @@ /** - * Dispatch against the real rule: every level the picker offers is accepted. + * Dispatch against the real rule, live: a toggle model's "On" is a wire value + * the gateway takes. * * Two readers decide whether a turn's saved thinking level survives. The * composer renders the Effort row the plane adapter answers, and dispatch @@ -8,18 +9,14 @@ * pi-ai's adapter, and when they disagreed `glm-5.3-flash-free` offered `high` * and `max` beside its name in the composer and answered * UNSUPPORTED_REASONING_EFFORT for both. Now both come from one rule - * (`reasoningFor`, D1) folded into one model object (`planeModelFor`), so - * this file is the check that the one rule and pi's agree. + * (`reasoningFor`, D1) folded into one model object (`planeModelFor`). * - * The unit suite (`test/model-metadata.test.ts`) pins the rule against pi's - * levels for the rule's own map. This file imports pi's rule ITSELF — - * `getSupportedThinkingLevels` from the installed `@earendil-works/pi-ai`, the - * function `dsh-llm-pi-ai` carries its own copy of and validates with — and - * sweeps the whole shipped catalog on both planes through the model object - * the plane adapter serves: for every model, **the levels the picker offers - * are exactly the levels the rule accepts**. + * The offline half — pi's own rule swept over the whole shipped catalog on both + * planes, the offered levels exactly the accepted ones — is + * `test/dispatch-levels.test.ts`, in the unit suite: it needs no network and no + * port. * - * The last block is live and keyed: one toggle-only model per plane and + * This block is live and keyed: one toggle-only model per plane and * protocol, sent through the plane adapter on a tier-A host with the effort id * the picker's "On" carries, so the adapter builds the wire value and a * gateway that refuses it fails here rather than in a user's turn. It runs on @@ -28,162 +25,53 @@ * lower tier, and when no toggle model was entitled on the account (none * reached the gateway's body check). * - * Opt-in (`OPENCODE_E2E=1`), like every file in this suite. The sweep needs - * no network and no port: its value is the real rule and the real catalog. + * Opt-in (`OPENCODE_E2E=1`), like every file in this suite. */ -import { readFile } from "node:fs/promises"; - -import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/models"; import { describe, expect, it } from "vitest"; +import { e2e } from "../support/e2e-env.ts"; import { drainStream, fakeCredentials, mountTierA, waitForPlaneOwner, } from "../support/host.ts"; -import { armedKey, keyedTierOf } from "../support/keyed-probes.ts"; import { type CatalogModelSpec, OPENCODE_FAMILY_API, OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, - planeModelFor, type Protocol, protocolForSdk, reasoningFor, } from "../support/src.ts"; -const ENABLED = process.env.OPENCODE_E2E === "1"; -/** What may spend a key: the live block runs on the `full` tier only. */ -const TIER = keyedTierOf(process.env.OPENCODE_E2E_KEYED); +/** The process's own live-suite environment, read once at import. */ +const E2E = e2e(); + +const ENABLED = E2E.live; -/** The two plane routes, the catalog and family map each resolves from, and its key. */ +/** + * The two plane routes, the catalog and family map each resolves from, and its + * key — armed on the `full` tier only (`E2E.keyFor`), which is what may spend + * one here. + */ const PLANES = [ { catalog: OPENCODE_ZEN_CATALOG, family: OPENCODE_FAMILY_API.zen, - key: armedKey(TIER, "full", process.env.OPENCODE_API_KEY), + key: E2E.keyFor("opencode", "full"), route: "opencode", }, { catalog: OPENCODE_GO_CATALOG, family: OPENCODE_FAMILY_API.go, - key: armedKey(TIER, "full", process.env.OPENCODE_GO_API_KEY), + key: E2E.keyFor("opencode-go", "full"), route: "opencode-go", }, ] as const; -/** The model the plane adapter serves for one catalog row, on the default family. */ -const servedModel = ( - plane: (typeof PLANES)[number], - spec: CatalogModelSpec -): Readonly> & { api: Protocol; baseUrl: string } => { - const { model, error } = planeModelFor({ - defaults: { contextWindow: 262_144, input: ["text"], maxTokens: 32_768 }, - endpoint: "modern", - entry: { id: spec.id }, - familyApi: plane.family, - route: plane.route, - spec, - }); - if (model === undefined) { - throw new Error(`${plane.route}/${spec.id}: ${error}`); - } - return model; -}; - -/** The levels the picker offers for a served model, as effort ids. */ -const offeredLevels = ( - spec: CatalogModelSpec, - model: Readonly> & { api: Protocol } -): string[] => - reasoningFor( - spec, - model.api, - model.compat as Readonly> | undefined - )?.efforts.map((effort) => effort.id) ?? ["off"]; - -/** - * The real rule. `getSupportedThinkingLevels` is typed against pi's `Model` - * union; the served model is the shape it reads two fields off, so the cast - * is the seam. - */ -const acceptedLevels = (model: unknown): string[] => - getSupportedThinkingLevels(model as never); - -describe.skipIf(!ENABLED)( - "the levels the picker offers are the levels dispatch accepts", - () => { - it("pins the deployed copy of the rule to the oracle it imports", async () => { - // `dsh-llm-pi-ai` carries its own copy of the rule — that copy is what - // the deployment validates with — and the oracle above imports the - // original from `@earendil-works/pi-ai`. The two are one rule by - // construction; this holds them together, so an upstream move fails here - // rather than quietly validating against the wrong thing. - const raw = await readFile( - new URL( - "../../node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js", - import.meta.url - ), - "utf8" - ); - const source = raw.replaceAll("!== void 0", "!== undefined"); - expect( - source, - "the deployed rule's reasoning-false branch left the bundle" - ).toContain('if (!model.reasoning) return ["off"];'); - expect( - source, - "the deployed rule's xhigh/max requirement left the bundle" - ).toContain( - 'if (level === "xhigh" || level === "max") return mapped !== undefined;' - ); - }); - - it("sweeps the whole catalog: the offered levels are exactly the accepted ones", () => { - const problems: string[] = []; - for (const plane of PLANES) { - let checked = 0; - for (const spec of plane.catalog) { - const model = servedModel(plane, spec); - // The catalog's protocol is the one dispatched on (D16). - expect(model.api).toBe(protocolForSdk(spec.provider_npm)); - const offered = offeredLevels(spec, model); - const accepted = acceptedLevels(model); - if (offered.join(",") !== accepted.join(",")) { - problems.push( - `${plane.route} ${spec.id}: offers [${offered.join(", ")}], dispatch takes [${accepted.join(", ")}]` - ); - } - if (spec.reasoning === true) { - checked += 1; - } - } - // Anti-vacuity: a plane whose rows stopped reasoning (a parser change, - // a catalog swap) must fail here, not pass by covering zero. - expect(checked, `no ${plane.route} model reasons`).toBeGreaterThan(0); - } - expect(problems).toEqual([]); - }); - - it("dispatches the turn that reported the bug", () => { - // `glm-5.3-flash-free` offered levels its dispatch refused. One rule - // now builds both, so whatever it offers is taken. - const [zen] = PLANES; - const spec = zen.catalog.find((row) => row.id === "glm-5.3-flash-free"); - if (spec === undefined) { - throw new TypeError("glm-5.3-flash-free left the Zen catalog"); - } - const model = servedModel(zen, spec); - const offered = offeredLevels(spec, model); - expect(offered.length).toBeGreaterThan(1); - expect(acceptedLevels(model)).toEqual(offered); - }); - } -); - /** The first toggle-only model of each protocol on a plane: its efforts are named "On". */ const toggleModels = ( plane: (typeof PLANES)[number] diff --git a/test/e2e/host-binary.e2e.ts b/test/e2e/host-binary.e2e.ts index 247c6bea..cc2d35a7 100644 --- a/test/e2e/host-binary.e2e.ts +++ b/test/e2e/host-binary.e2e.ts @@ -53,6 +53,7 @@ import { fileURLToPath } from "node:url"; import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { e2e } from "../support/e2e-env.ts"; import { HOST_VERSIONS } from "../support/host-versions.ts"; import { CHARACTERIZED_MODELS, @@ -70,9 +71,12 @@ import { OPENCODE_ZEN_CATALOG, } from "../support/src.ts"; -const ENABLED = process.env.DSH_HOST_E2E === "1"; -const PREFIX = process.env.DSH_HOST_PREFIX ?? ""; -const CELL = process.env.DSH_HOST_PI_CELL ?? "pinned"; +/** The process's own live-suite environment, read once at import. */ +const E2E = e2e(); + +const ENABLED = E2E.hostBinary.enabled; +const PREFIX = E2E.hostBinary.prefix; +const CELL = E2E.hostBinary.cell; const PINNED = CELL === "pinned"; const BOOT_DEADLINE_MS = 180_000; const CHECKOUT = fileURLToPath(new URL("../..", import.meta.url)); @@ -306,7 +310,7 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { DSH_HOME: dshHome, DSH_TELEMETRY_DISABLED: "1", HOME: home, - PATH: process.env.PATH ?? "", + PATH: E2E.hostBinary.path, }; const dsh = (args: string[]) => { const run = spawnSync(process.execPath, [bin, ...args], { @@ -413,7 +417,7 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { const written = existsSync(probeOut) ? readFileSync(probeOut, "utf8") : undefined; - const copy = process.env.DSH_HOST_PROBE_COPY ?? ""; + const copy = E2E.hostBinary.probeCopy; if (written !== undefined && copy.length > 0) { writeFileSync(copy, written); } diff --git a/test/e2e/keyed-probes.e2e.ts b/test/e2e/keyed-probes.e2e.ts index 5a04d286..6b36d94d 100644 --- a/test/e2e/keyed-probes.e2e.ts +++ b/test/e2e/keyed-probes.e2e.ts @@ -71,17 +71,16 @@ import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { e2e } from "../support/e2e-env.ts"; import { type RecordedProbe, recordTable, runReference, } from "../support/endpoint-record.ts"; import { - armedKey, excerpt, isArmed, keyedCell, - keyedTierOf, keyedVerdictOf, planeOutcome, } from "../support/keyed-probes.ts"; @@ -95,15 +94,16 @@ import { } from "../support/keyless-matrix.ts"; import { MODELS_OPENCODE_URL, parseModelsDevCatalog } from "../support/src.ts"; -const LIVE = process.env.OPENCODE_E2E === "1"; +/** The process's own live-suite environment, read once at import. */ +const E2E = e2e(); + +const LIVE = E2E.live; /** A mirror the rest of the suite is pointed at, which this file cannot follow. */ -const MIRRORED = ["OPENCODE_ZEN_BASE_URL", "OPENCODE_GO_BASE_URL"].some( - (name) => (process.env[name] ?? "").length > 0 -); -const TIER = keyedTierOf(process.env.OPENCODE_E2E_KEYED); +const MIRRORED = E2E.mirrored; +const TIER = E2E.tier; const KEYS = { - opencode: armedKey(TIER, "probes", process.env.OPENCODE_API_KEY), - "opencode-go": armedKey(TIER, "probes", process.env.OPENCODE_GO_API_KEY), + opencode: E2E.keyFor("opencode", "probes"), + "opencode-go": E2E.keyFor("opencode-go", "probes"), } as const; /** Per request: protocol-routing's measured budget, so a hung gateway cannot outlast the job. */ @@ -186,17 +186,13 @@ describe.skipIf( const record = `${recordTable({ date: new Date().toISOString().slice(0, 10), probes: recorded, - run: runReference({ - repository: process.env.GITHUB_REPOSITORY, - runId: process.env.GITHUB_RUN_ID, - server: process.env.GITHUB_SERVER_URL, - }), + run: runReference(E2E.run), title: "Keyed", })}\n`; if (print) { console.info(record); } - const dir = process.env.OPENCODE_E2E_RECORD_DIR ?? ""; + const dir = E2E.recordDir; if (dir.length > 0) { await mkdir(dir, { recursive: true }); await writeFile(join(dir, "keyed.md"), record); diff --git a/test/e2e/keyless-matrix.e2e.ts b/test/e2e/keyless-matrix.e2e.ts index cf231a46..301efb1d 100644 --- a/test/e2e/keyless-matrix.e2e.ts +++ b/test/e2e/keyless-matrix.e2e.ts @@ -54,6 +54,7 @@ import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { e2e } from "../support/e2e-env.ts"; import { freeProbeLine, type RecordedProbe, @@ -87,11 +88,12 @@ import { parseModelsDevCatalog, } from "../support/src.ts"; -const LIVE = process.env.OPENCODE_E2E === "1"; +/** The process's own live-suite environment, read once at import. */ +const E2E = e2e(); + +const LIVE = E2E.live; /** A mirror the rest of the suite is pointed at, which this file cannot follow. */ -const MIRRORED = ["OPENCODE_ZEN_BASE_URL", "OPENCODE_GO_BASE_URL"].some( - (name) => (process.env[name] ?? "").length > 0 -); +const MIRRORED = E2E.mirrored; if (LIVE && MIRRORED) { console.info( "[keyless-matrix] skipped: a mirror base URL is set, and the matrix measures only the vendor's own paths" @@ -213,11 +215,7 @@ describe.skipIf(!LIVE || MIRRORED)( recordTable({ date: new Date().toISOString().slice(0, 10), probes: recorded, - run: runReference({ - repository: process.env.GITHUB_REPOSITORY, - runId: process.env.GITHUB_RUN_ID, - server: process.env.GITHUB_SERVER_URL, - }), + run: runReference(E2E.run), title: "Keyless", }), "", @@ -227,7 +225,7 @@ describe.skipIf(!LIVE || MIRRORED)( if (print) { console.info(record); } - const dir = process.env.OPENCODE_E2E_RECORD_DIR ?? ""; + const dir = E2E.recordDir; if (dir.length > 0) { await mkdir(dir, { recursive: true }); await writeFile(join(dir, "keyless.md"), record); diff --git a/test/e2e/opencode-live.e2e.ts b/test/e2e/opencode-live.e2e.ts index 156797d4..3c1f5c0c 100644 --- a/test/e2e/opencode-live.e2e.ts +++ b/test/e2e/opencode-live.e2e.ts @@ -21,7 +21,7 @@ import { describe, expect, it } from "vitest"; -import { armedKey, keyedTierOf } from "../support/keyed-probes.ts"; +import { e2e } from "../support/e2e-env.ts"; import { type GoUsage, OPENCODE_ZEN_CATALOG, @@ -31,18 +31,18 @@ import { parseGoUsage, } from "../support/src.ts"; -const LIVE = process.env.OPENCODE_E2E === "1"; +/** The process's own live-suite environment, read once at import. */ +const E2E = e2e(); + +const LIVE = E2E.live; /** Bases are overridable so the suite can be pointed at a mirror or a local stub. */ -const ZEN_BASE = - process.env.OPENCODE_ZEN_BASE_URL ?? "https://opencode.ai/zen/v1"; -const GO_BASE = - process.env.OPENCODE_GO_BASE_URL ?? "https://opencode.ai/zen/go/v1"; +const ZEN_BASE = E2E.baseOverride.zen ?? "https://opencode.ai/zen/v1"; +const GO_BASE = E2E.baseOverride.go ?? "https://opencode.ai/zen/go/v1"; /** What may spend a key: the keyed blocks here run on the `full` tier only. */ -const TIER = keyedTierOf(process.env.OPENCODE_E2E_KEYED); -const ZEN_KEY = armedKey(TIER, "full", process.env.OPENCODE_API_KEY); -const GO_KEY = armedKey(TIER, "full", process.env.OPENCODE_GO_API_KEY); +const ZEN_KEY = E2E.keyFor("opencode", "full"); +const GO_KEY = E2E.keyFor("opencode-go", "full"); /** Generous: these cross the public internet from CI. */ const TIMEOUT_MS = 30_000; diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index 4150d453..626a898b 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -67,7 +67,7 @@ import { describe, expect, it } from "vitest"; -import { armedKey, keyedTierOf } from "../support/keyed-probes.ts"; +import { e2e } from "../support/e2e-env.ts"; import { baseUrlFor, GO_GOOGLE_BASE_URL, @@ -80,24 +80,27 @@ import { findModelSpec, findModelSpecOn, isServableSdk, + openCodeSessionIdFor, planeModelFor, } from "../support/src.ts"; -const LIVE = process.env.OPENCODE_E2E === "1"; +/** The process's own live-suite environment, read once at import. */ +const E2E = e2e(); + +const LIVE = E2E.live; /** What may spend a key: the keyed cases here run on the `full` tier only. */ -const TIER = keyedTierOf(process.env.OPENCODE_E2E_KEYED); -const ZEN_KEY = armedKey(TIER, "full", process.env.OPENCODE_API_KEY); +const ZEN_KEY = E2E.keyFor("opencode", "full"); /** * The Go plane. A different base URL AND a different credential — which is why * probing its models against the Zen base proves nothing about them. */ -const GO_KEY = armedKey(TIER, "full", process.env.OPENCODE_GO_API_KEY); +const GO_KEY = E2E.keyFor("opencode-go", "full"); /** A base to probe instead of the plugin's own, per plane (a mirror or a stub). */ const BASE_OVERRIDE: Readonly> = { - go: process.env.OPENCODE_GO_BASE_URL, - zen: process.env.OPENCODE_ZEN_BASE_URL, + go: E2E.baseOverride.go, + zen: E2E.baseOverride.zen, }; /** Generous: these cross the public internet from CI. */ @@ -192,14 +195,16 @@ const BODY_FOR: Readonly>> = { /** * Headers the plugin injects, so a probe looks like the traffic we actually * send: a turn's user agent is `OPENCODE_UA`, which the fetch patch stamps — - * `OPENCODE_PATCH_USER_AGENT` signs only the plugin's own requests. + * `OPENCODE_PATCH_USER_AGENT` signs only the plugin's own requests — and a + * session id derived the plugin's own way, so it is in the band the gateway + * admits by construction rather than a hand-written value it refuses. */ const PLUGIN_HEADERS: Readonly> = { "content-type": "application/json", "user-agent": OPENCODE_UA, "x-opencode-client": "cli", "x-opencode-project": "global", - "x-opencode-session": "ses_e2e0000000000abcdefghij", + "x-opencode-session": openCodeSessionIdFor("protocol-routing"), }; interface Probe { diff --git a/test/endpoint-completions.test.ts b/test/endpoint-completions.test.ts deleted file mode 100644 index a1e28ed6..00000000 --- a/test/endpoint-completions.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Test: OpenAI v1 Chat Completions Endpoint (/chat/completions). - * - * Validates the OpenAI v1 Chat Completions wire protocol: - * - Endpoint: /chat/completions (or /zen/v1/chat/completions) - * - Protocol: openai-completions (v1) - * - Streaming SSE response delivery with v1 chunks and `data: [DONE]` sentinel - * - Unary JSON response delivery with `choices[0].message` structure - * - Protocol validation (accepts openai-completions models, rejects others with 400) - * - Wire header validation (Authorization: Bearer ) - * - * Note: Stream wire format is parsed and understood by the host's `pi-ai` adapter, - * not hand-decoded here. - * - * @module test/endpoint-completions.test - */ - -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import type { - GatewayErrorPayload, - V1CompletionsUnaryPayload, -} from "./mock/protocols/index.ts"; -import { - GatewayTestRunner, - MOCK_SESSION, - ZEN_KEY, -} from "./mock/testing-kit.ts"; -import { clearCapturedApiKeys } from "./support/src.ts"; - -let runner: GatewayTestRunner; - -beforeAll(async () => { - runner = new GatewayTestRunner(); - await runner.start(); -}); - -afterAll(async () => { - await runner.close(); -}); - -beforeEach(() => { - runner.clearRequests(); - clearCapturedApiKeys(); - vi.stubEnv("OPENCODE_API_KEY", ZEN_KEY); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -describe("OpenAI v1 Chat Completions Endpoint (/chat/completions)", () => { - it("delivers streaming SSE chunk response with completion data and [DONE] sentinel", async () => { - const result = await runner.execute( - "opencode", - "/zen/v1/chat/completions", - { - body: { - messages: [{ content: "hi", role: "user" }], - model: "beam-free", - stream: true, - }, - } - ); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("text/event-stream"); - expect(result.text).toContain("chat.completion.chunk"); - expect(result.text).toContain("Hello from "); - expect(result.text).toContain("the mock gateway."); - expect(result.text).toContain("[DONE]"); - - const req = result.request!; - expect(req.kind).toBe("completions"); - expect(req.protocol).toBe("openai-completions"); - expect(req.model).toBe("beam-free"); - expect(req.headers.authorization).toBe(`Bearer ${ZEN_KEY}`); - expect(req.headers["x-opencode-session-id"]).toBe(MOCK_SESSION); - }); - - it("delivers unary application/json response when stream is false", async () => { - const result = await runner.execute( - "opencode", - "/zen/v1/chat/completions", - { - body: { - messages: [{ content: "hi", role: "user" }], - model: "beam-free", - stream: false, - }, - } - ); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("application/json"); - - const body = result.json(); - expect(body.object).toBe("chat.completion"); - expect(body.choices[0]!.message.content).toBe( - "Hello from the mock gateway." - ); - expect(body.choices[0]!.finish_reason).toBe("stop"); - expect(body.usage.total_tokens).toBeGreaterThan(0); - }); - - it("refuses models not served by openai-completions with 400 ModelProtocolUnsupported", async () => { - const result = await runner.execute( - "opencode", - "/zen/v1/chat/completions", - { - body: { - messages: [{ content: "hi", role: "user" }], - model: "gpt-5.4", - stream: true, - }, - } - ); - - expect(result.status).toBe(400); - const body = result.json(); - expect(body.error.type).toBe("model_protocol_unsupported"); - expect(body.error.message).toContain("ModelProtocolUnsupported"); - expect(result.request?.reject).toBe("protocol"); - }); -}); diff --git a/test/endpoint-google.test.ts b/test/endpoint-google.test.ts deleted file mode 100644 index d1c7f0eb..00000000 --- a/test/endpoint-google.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Test: Google Generative AI Endpoint. - * - * Validates: - * - URL path model extraction: /models/{model}:streamGenerateContent vs :generateContent - * - Streaming SSE response delivery via :streamGenerateContent - * - Unary JSON response delivery via :generateContent - * - Authentication via x-goog-api-key wire header - * - Protocol validation (accepts google-generative-ai models, rejects others with 400) - * - * Note: Stream wire format is parsed and understood by the host's `pi-ai` adapter, - * not hand-decoded here. - * - * @module test/endpoint-google.test - */ - -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import type { - GatewayErrorPayload, - GoogleUnaryPayload, -} from "./mock/protocols/index.ts"; -import { - GatewayTestRunner, - MOCK_SESSION, - ZEN_KEY, -} from "./mock/testing-kit.ts"; -import { clearCapturedApiKeys } from "./support/src.ts"; - -let runner: GatewayTestRunner; - -beforeAll(async () => { - runner = new GatewayTestRunner(); - await runner.start(); -}); - -afterAll(async () => { - await runner.close(); -}); - -beforeEach(() => { - runner.clearRequests(); - clearCapturedApiKeys(); - vi.stubEnv("OPENCODE_API_KEY", ZEN_KEY); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -describe("Google Generative AI Endpoint", () => { - it("delivers streaming SSE response via /models/{model}:streamGenerateContent", async () => { - const result = await runner.execute( - "opencode", - "/zen/v1/models/gemini-3.1-pro:streamGenerateContent?alt=sse", - { - body: { contents: [{ parts: [{ text: "hi" }], role: "user" }] }, - } - ); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("text/event-stream"); - expect(result.text).toContain("Hello from "); - expect(result.text).toContain("the mock gateway."); - expect(result.text).toContain("STOP"); - - const req = result.request!; - expect(req.kind).toBe("google"); - expect(req.protocol).toBe("google-generative-ai"); - expect(req.model).toBe("gemini-3.1-pro"); - expect(req.headers["x-goog-api-key"]).toBe(ZEN_KEY); - expect(req.headers["x-opencode-session-id"]).toBe(MOCK_SESSION); - }); - - it("delivers unary application/json response via /models/{model}:generateContent", async () => { - const result = await runner.execute( - "opencode", - "/zen/v1/models/gemini-3.1-pro:generateContent", - { - body: { contents: [{ parts: [{ text: "hi" }], role: "user" }] }, - } - ); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("application/json"); - - const body = result.json(); - expect(body.modelVersion).toBe("gemini-3.1-pro"); - expect(body.candidates[0]!.content.parts[0]!.text).toBe( - "Hello from the mock gateway." - ); - expect(body.candidates[0]!.finishReason).toBe("STOP"); - expect(body.usageMetadata.totalTokenCount).toBeGreaterThan(0); - }); - - it("extracts model ID from URL path rather than JSON body", async () => { - const result = await runner.execute( - "opencode", - "/zen/v1/models/gemini-3.1-pro:generateContent", - { - body: { contents: [{ parts: [{ text: "hello" }], role: "user" }] }, - } - ); - - expect(result.status).toBe(200); - expect(result.request?.model).toBe("gemini-3.1-pro"); - }); - - it("refuses models not served by google-generative-ai with 400 ModelProtocolUnsupported", async () => { - const result = await runner.execute( - "opencode", - "/zen/v1/models/gpt-5.4:generateContent", - { - body: { contents: [{ parts: [{ text: "hi" }], role: "user" }] }, - } - ); - - expect(result.status).toBe(400); - const body = result.json(); - expect(body.error.type).toBe("model_protocol_unsupported"); - expect(body.error.message).toContain("ModelProtocolUnsupported"); - expect(result.request?.reject).toBe("protocol"); - }); -}); diff --git a/test/endpoint-messages.test.ts b/test/endpoint-messages.test.ts deleted file mode 100644 index 345d05fa..00000000 --- a/test/endpoint-messages.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Test: Anthropic Messages Endpoint (/messages). - * - * Validates: - * - Streaming SSE events (message_start, content_block_delta, message_delta, message_stop) - * - Unary JSON response delivery when stream is false - * - Authentication via x-api-key wire header - * - Protocol validation (accepts anthropic-messages models, rejects others with 400) - * - * Note: Stream wire format is parsed and understood by the host's `pi-ai` adapter, - * not hand-decoded here. - * - * @module test/endpoint-messages.test - */ - -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import type { - GatewayErrorPayload, - MessagesUnaryPayload, -} from "./mock/protocols/index.ts"; -import { - GatewayTestRunner, - MOCK_SESSION, - ZEN_KEY, -} from "./mock/testing-kit.ts"; -import { clearCapturedApiKeys } from "./support/src.ts"; - -let runner: GatewayTestRunner; - -beforeAll(async () => { - runner = new GatewayTestRunner(); - await runner.start(); -}); - -afterAll(async () => { - await runner.close(); -}); - -beforeEach(() => { - runner.clearRequests(); - clearCapturedApiKeys(); - vi.stubEnv("OPENCODE_API_KEY", ZEN_KEY); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -describe("Anthropic Messages Endpoint (/messages)", () => { - it("delivers streaming SSE response with content_block_delta events", async () => { - const result = await runner.execute("opencode", "/zen/v1/messages", { - body: { - max_tokens: 64, - messages: [{ content: "hi", role: "user" }], - model: "claude-sonnet-4-6", - stream: true, - }, - }); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("text/event-stream"); - expect(result.text).toContain("message_start"); - expect(result.text).toContain("content_block_delta"); - expect(result.text).toContain("Hello from "); - expect(result.text).toContain("the mock gateway."); - expect(result.text).toContain("message_stop"); - - const req = result.request!; - expect(req.kind).toBe("messages"); - expect(req.protocol).toBe("anthropic-messages"); - expect(req.model).toBe("claude-sonnet-4-6"); - expect(req.headers["x-api-key"]).toBe(ZEN_KEY); - expect(req.headers["x-opencode-session-id"]).toBe(MOCK_SESSION); - }); - - it("delivers unary application/json response when stream is false", async () => { - const result = await runner.execute("opencode", "/zen/v1/messages", { - body: { - max_tokens: 64, - messages: [{ content: "hi", role: "user" }], - model: "claude-sonnet-4-6", - stream: false, - }, - }); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("application/json"); - - const body = result.json(); - expect(body.type).toBe("message"); - expect(body.role).toBe("assistant"); - expect(body.content[0]!.text).toBe("Hello from the mock gateway."); - expect(body.stop_reason).toBe("end_turn"); - expect(body.usage.output_tokens).toBeGreaterThan(0); - }); - - it("refuses models not served by anthropic-messages with 400 ModelProtocolUnsupported", async () => { - const result = await runner.execute("opencode", "/zen/v1/messages", { - body: { - max_tokens: 64, - messages: [{ content: "hi", role: "user" }], - model: "gpt-5.4", - stream: true, - }, - }); - - expect(result.status).toBe(400); - const body = result.json(); - expect(body.error.type).toBe("model_protocol_unsupported"); - expect(body.error.message).toContain("ModelProtocolUnsupported"); - expect(result.request?.reject).toBe("protocol"); - }); -}); diff --git a/test/endpoint-models-catalog.test.ts b/test/endpoint-models-catalog.test.ts deleted file mode 100644 index addf2978..00000000 --- a/test/endpoint-models-catalog.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Test: Models Catalog Listing Endpoint (/models). - * - * Validates: - * - GET /models listing structure (data array of model objects) - * - Transparent enrichment when enrichModels is enabled (adds metadata, display names, costs) - * - Raw gateway listing preservation when enrichModels is disabled - * - Session header compliance on /models requests - * - Method validation (GET allowed, POST refused with 405) - * - * @module test/endpoint-models-catalog.test - */ - -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import type { - CatalogListingPayload, - GatewayErrorPayload, -} from "./mock/protocols/index.ts"; -import { - GatewayTestRunner, - MOCK_SESSION, - ZEN_KEY, -} from "./mock/testing-kit.ts"; -import { clearCapturedApiKeys } from "./support/src.ts"; - -let runner: GatewayTestRunner; - -beforeAll(async () => { - runner = new GatewayTestRunner(); - await runner.start(); -}); - -afterAll(async () => { - await runner.close(); -}); - -beforeEach(() => { - runner.clearRequests(); - clearCapturedApiKeys(); - vi.stubEnv("OPENCODE_API_KEY", ZEN_KEY); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -describe("Models Catalog Listing Endpoint (/models)", () => { - it("delivers models catalog listing with transparent enrichment when enabled", async () => { - const result = await runner.execute("opencode", "/zen/v1/models", { - config: { enrichModels: true }, - }); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("application/json"); - - const body = result.json(); - expect(body.object).toBe("list"); - expect(Array.isArray(body.data)).toBe(true); - expect(body.data.length).toBeGreaterThan(0); - - const req = result.request!; - expect(req.kind).toBe("models"); - expect(req.method).toBe("GET"); - expect(req.headers["x-opencode-session-id"]).toBe(MOCK_SESSION); - }); - - it("leaves raw gateway listing alone when enrichModels is false", async () => { - const result = await runner.execute("opencode", "/zen/v1/models", { - config: { enrichModels: false }, - }); - - expect(result.status).toBe(200); - const body = result.json(); - expect(body.object).toBe("list"); - expect(body.data.some((m) => m.id === "mock-unknown-model")).toBe(true); - }); - - it("refuses POST method on /models with 405 MethodNotAllowed", async () => { - const result = await runner.execute("opencode", "/zen/v1/models", { - method: "POST", - }); - - expect(result.status).toBe(405); - const body = result.json(); - expect(body.error.type).toBe("method_not_allowed"); - expect(result.request?.reject).toBe("method"); - }); -}); diff --git a/test/endpoint-responses.test.ts b/test/endpoint-responses.test.ts deleted file mode 100644 index 31219a28..00000000 --- a/test/endpoint-responses.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Test: OpenAI v2 Responses API Endpoint (/responses). - * - * Validates the OpenAI v2 Responses wire protocol: - * - Endpoint: /responses (or /zen/v1/responses) - * - Protocol: openai-responses (v2) - * - Streaming SSE response delivery with typed events: - * (response.created, response.output_text.delta, response.completed) - * - Unary JSON response generation when stream is false - * - Automatic core tool injection (read, bash) for free-tier models (e.g. beam-free) - * - Tool omission for paid models (e.g. gpt-5.4) - * - Configurable injectCoreTools toggle - * - Wire header validation and session header compliance - * - * Note: Stream wire format is parsed and understood by the host's `pi-ai` adapter, - * not hand-decoded here. - * - * @module test/endpoint-responses.test - */ - -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import type { V2ResponsesUnaryPayload } from "./mock/protocols/index.ts"; -import { - GatewayTestRunner, - MOCK_SESSION, - MOCK_TEXT, - ZEN_KEY, -} from "./mock/testing-kit.ts"; -import { clearCapturedApiKeys } from "./support/src.ts"; - -let runner: GatewayTestRunner; - -beforeAll(async () => { - runner = new GatewayTestRunner(); - await runner.start(); -}); - -afterAll(async () => { - await runner.close(); -}); - -beforeEach(() => { - runner.clearRequests(); - clearCapturedApiKeys(); - vi.stubEnv("OPENCODE_API_KEY", ZEN_KEY); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -describe("OpenAI v2 Responses Endpoint (/responses)", () => { - it("delivers full streaming SSE response with deltas and completion events", async () => { - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - }); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("text/event-stream"); - expect(result.text).toContain("response.created"); - expect(result.text).toContain("response.output_text.delta"); - expect(result.text).toContain("Hello from "); - expect(result.text).toContain("the mock gateway."); - expect(result.text).toContain("response.completed"); - - const req = result.request!; - expect(req.kind).toBe("responses"); - expect(req.protocol).toBe("openai-responses"); - expect(req.model).toBe("gpt-5.4"); - expect(req.headers["x-opencode-session-id"]).toBe(MOCK_SESSION); - }); - - it("delivers unary application/json response when stream is false", async () => { - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: false }, - }); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("application/json"); - - const body = result.json(); - expect(body.id).toBe("resp_mock"); - expect(body.object).toBe("response"); - expect(body.model).toBe("gpt-5.4"); - expect(body.status).toBe("completed"); - expect(body.output[0]!.content[0]!.text).toBe(MOCK_TEXT); - expect(body.usage.total_tokens).toBeGreaterThan(0); - }); - - it("injects read and bash core tools for free-tier completions models", async () => { - // `beam-free` is the active free fixture, and the free tier is served on - // /chat/completions — the vendor's own per-model protocol table has no - // free model on /responses any more (the last one, - // muse-spark-1.2-contributor-free, was deprecated upstream). - const result = await runner.execute( - "opencode", - "/zen/v1/chat/completions", - { - body: { - messages: [{ content: "hi", role: "user" }], - model: "beam-free", - stream: true, - }, - config: { injectCoreTools: true }, - } - ); - - expect(result.status).toBe(200); - const recorded = result.request!; - expect(recorded.body).toBeDefined(); - - const body = recorded.body as { - tools?: { name?: string; function?: { name?: string } }[]; - }; - expect(body.tools).toBeDefined(); - const toolNames = body.tools!.map((t) => t.function?.name ?? t.name); - expect(toolNames).toContain("read"); - expect(toolNames).toContain("bash"); - }); - - it("omits core tool injection for paid models", async () => { - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - config: { injectCoreTools: true }, - }); - - expect(result.status).toBe(200); - const recorded = result.request!; - const body = recorded.body as { tools?: unknown[] }; - expect(body.tools).toBeUndefined(); - }); - - it("respects injectCoreTools: false, and the gateway then refuses a free model", async () => { - // Turning the fallback off is honoured — nothing is injected — and the - // consequence is now visible rather than implied: a free-tier body with no - // tools answers `403 FreeTierError` (measured against the live gateway, and - // modelled by the mock). The configuration is the operator's choice; the - // refusal is the gateway's. - const result = await runner.execute( - "opencode", - "/zen/v1/chat/completions", - { - body: { - messages: [{ content: "hi", role: "user" }], - model: "beam-free", - stream: true, - }, - config: { injectCoreTools: false }, - } - ); - - expect(result.status).toBe(403); - const recorded = result.request!; - const body = recorded.body as { tools?: unknown[] }; - expect(body.tools).toBeUndefined(); - }); -}); diff --git a/test/endpoint-usage-quota.test.ts b/test/endpoint-usage-quota.test.ts deleted file mode 100644 index cc3cae0e..00000000 --- a/test/endpoint-usage-quota.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Test: OpenCode Go Usage & Quota Endpoint (/usage). - * - * Validates: - * - GET /usage and /zen/go/v1/usage quota window parsing (rolling, weekly, monthly) - * - Required wire headers (x-opencode-client: cli, x-opencode-project: global, Authorization: Bearer) - * - Entitlement handling (403 EntitlementError and Zen overflow) - * - Quota percent and status validation - * - * @module test/endpoint-usage-quota.test - */ - -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import type { UsageQuotaPayload } from "./mock/protocols/index.ts"; -import { GatewayTestRunner, GO_KEY } from "./mock/testing-kit.ts"; -import { clearCapturedApiKeys } from "./support/src.ts"; - -let runner: GatewayTestRunner; - -beforeAll(async () => { - runner = new GatewayTestRunner(); - await runner.start(); -}); - -afterAll(async () => { - await runner.close(); -}); - -beforeEach(() => { - runner.clearRequests(); - clearCapturedApiKeys(); - vi.stubEnv("OPENCODE_GO_API_KEY", GO_KEY); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -describe("OpenCode Go Usage & Quota Endpoint", () => { - it("delivers quota windows via GET /zen/go/v1/usage", async () => { - const result = await runner.execute("opencode-go", "/zen/go/v1/usage", {}); - - expect(result.status).toBe(200); - expect(result.headers.get("content-type")).toContain("application/json"); - - const body = result.json(); - expect(body.rolling).toBeDefined(); - expect(body.rolling.percent).toBeDefined(); - expect(body.rolling.status).toBe("ok"); - expect(body.monthly).toBeDefined(); - expect(body.weekly).toBeDefined(); - - const req = result.request!; - expect(req.plane).toBe("opencode-go"); - expect(req.kind).toBe("usage"); - expect(req.headers.authorization).toBe(`Bearer ${GO_KEY}`); - }); - - it("handles standalone GET /usage endpoint", async () => { - const result = await runner.execute("opencode-go", "/usage", {}); - - expect(result.status).toBe(200); - expect(result.request?.kind).toBe("usage"); - expect(result.request?.headers.authorization).toBe(`Bearer ${GO_KEY}`); - }); - - it("simulates 403 EntitlementError when Go subscription is not active", async () => { - const entitlementRunner = new GatewayTestRunner(); - await entitlementRunner.start({ - usage: { - body: JSON.stringify({ - error: { - message: "EntitlementError: subscription required", - type: "entitlement_error", - }, - }), - status: 403, - }, - }); - - try { - const result = await entitlementRunner.execute( - "opencode-go", - "/zen/go/v1/usage", - {} - ); - expect(result.status).toBe(403); - expect(result.text).toContain("EntitlementError"); - } finally { - await entitlementRunner.close(); - } - }); - - it("refuses unauthenticated usage requests with 401 Invalid API key", async () => { - vi.stubEnv("OPENCODE_GO_API_KEY", ""); - const result = await runner.execute("opencode-go", "/zen/go/v1/usage", {}); - - expect(result.status).toBe(401); - expect(result.request?.reject).toBe("credential"); - }); -}); diff --git a/test/fetch-patch-headers.test.ts b/test/fetch-patch-headers.test.ts new file mode 100644 index 00000000..bb0b2c6d --- /dev/null +++ b/test/fetch-patch-headers.test.ts @@ -0,0 +1,462 @@ +/** + * patchFetch · the identity and session headers a claimed request carries. + * + * @module test/fetch-patch-headers.test + */ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + GO_FREE_PROBE, + seedDivergentCatalog, +} from "./support/catalog-fixture.ts"; +import { + ANTHROPIC_VERSION, + OPENCODE_UA, + SESSION_AFFINITY_HEADER, + SESSION_HEADER, + SESSION_ID_HEADER, + clearCapturedApiKeys, + hasSessionHeader, + isOpenCodeSessionId, + openCodeSessionIdFor, + patchFetch, + resolveConfig, + type ActiveTurnState, +} from "./support/src.ts"; +import { + createCaptureFetch, + headerOf, + parseJsonBody, + toolNamesOf, +} from "./test-helpers.ts"; + +/** A session id a deployment pinned in its environment, valid by derivation. */ +const PINNED = openCodeSessionIdFor("pinned-env"); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENCODE_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + delete process.env.OPENCODE_SESSION_ID; + delete process.env.XDG_DATA_HOME; + clearCapturedApiKeys(); +}); + +describe("patchFetch · headers", () => { + it("fills anthropic-version on a Messages request that lacks it, keeps a caller's own, and adds none on an Authorization protocol", async () => { + // Nothing below the plugin sends the version (pi-ai's anthropic adapter + // does not), and the gateway treats a Messages call without it as a client + // error, so the plugin supplies it — and only where the protocol reads its + // credential from x-api-key. + process.env.OPENCODE_API_KEY = "sk-zen-version-test"; + const als = new AsyncLocalStorage(); + const versionOn = async ( + url: string, + headers: Record = {} + ): Promise => { + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch(mockFetch, als, resolveConfig()); + await patched(url, { body: "{}", headers, method: "POST" }); + return headerOf(capture.init, "anthropic-version"); + }; + + const messages = "https://opencode.ai/zen/v1/messages"; + expect(await versionOn(messages)).toBe(ANTHROPIC_VERSION); + expect( + await versionOn(messages, { "anthropic-version": "2099-01-01" }) + ).toBe("2099-01-01"); + expect( + await versionOn("https://opencode.ai/zen/v1/chat/completions") + ).toBeNull(); + }); + + it("passes non-OpenCode requests through completely untouched", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch("upstream-ok"); + + const patched = patchFetch(mockFetch, als, resolveConfig()); + const res = await patched("https://api.deepseek.com/v1/chat/completions", { + body: JSON.stringify({ message: "hello" }), + headers: { "X-Custom-Header": "original" }, + method: "POST", + }); + + expect(await res.text()).toBe("upstream-ok"); + expect(capture.url).toBe("https://api.deepseek.com/v1/chat/completions"); + expect(headerOf(capture.init, "X-Custom-Header")).toBe("original"); + expect(headerOf(capture.init, "User-Agent")).toBeNull(); + expect(headerOf(capture.init, "x-opencode-client")).toBeNull(); + expect(headerOf(capture.init, "x-opencode-project")).toBeNull(); + expect(headerOf(capture.init, SESSION_HEADER)).toBeNull(); + }); + + it("injects origin headers and dynamic session ID for zen requests", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const testSession = openCodeSessionIdFor("test-dynamic-turn"); + + const patched = patchFetch(mockFetch, als, resolveConfig()); + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched("https://opencode.ai/zen/v1/chat/completions", { + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + + expect(headerOf(capture.init, "User-Agent")).toBe(OPENCODE_UA); + expect(headerOf(capture.init, "x-opencode-client")).toBe("cli"); + expect(headerOf(capture.init, "x-opencode-project")).toBe("global"); + expect(headerOf(capture.init, SESSION_HEADER)).toBe(testSession); + }); + + it("restores configurable origin header values", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch( + mockFetch, + als, + resolveConfig({ originClient: "desktop", injectProject: true }) + ); + + await als.run( + { provider: "opencode", value: "ses_origins_1" }, + async () => { + await patched("https://opencode.ai/zen/v1/chat/completions", { + method: "POST", + }); + } + ); + expect(headerOf(capture.init, "x-opencode-client")).toBe("desktop"); + expect(headerOf(capture.init, "x-opencode-project")).toBe("global"); + }); + + it("dynamically resolves x-opencode-project from workspace when injectProject is on, and omits when off", async () => { + const als = new AsyncLocalStorage(); + const { capture: c1, mockFetch: m1 } = createCaptureFetch(); + const patched1 = patchFetch(m1, als, resolveConfig()); + + // When default (injectProject: true), dynamic project from workspace turn state is used: + await als.run( + { + project: "my-web-workspace", + provider: "opencode", + value: "ses_dyn_proj", + }, + async () => { + await patched1("https://opencode.ai/zen/v1/chat/completions", { + method: "POST", + }); + } + ); + expect(headerOf(c1.init, "x-opencode-project")).toBe("my-web-workspace"); + + // When injectProject: false, header is completely omitted: + const { capture: c2, mockFetch: m2 } = createCaptureFetch(); + const patched2 = patchFetch( + m2, + als, + resolveConfig({ injectProject: false }) + ); + await als.run( + { + project: "my-web-workspace", + provider: "opencode", + value: "ses_dyn_proj", + }, + async () => { + await patched2("https://opencode.ai/zen/v1/chat/completions", { + method: "POST", + }); + } + ); + expect(headerOf(c2.init, "x-opencode-project")).toBeNull(); + }); + + it("uses the configured session id env outside a turn", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + process.env.MY_SESSION_ID = PINNED; + try { + const patched = patchFetch( + mockFetch, + als, + resolveConfig({ sessionIdEnv: "MY_SESSION_ID" }) + ); + await patched("https://opencode.ai/zen/v1/chat/completions", { + method: "POST", + }); + expect(headerOf(capture.init, SESSION_HEADER)).toBe(PINNED); + } finally { + delete process.env.MY_SESSION_ID; + } + }); + + it("injects for configured custom relays when turn state matches", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const config = resolveConfig({ providers: ["my-relay"] }); + const patched = patchFetch(mockFetch, als, config); + + await als.run({ provider: "my-relay", value: "ses_relay_1" }, async () => { + await patched("https://relay.internal/v1/chat", { method: "POST" }); + }); + expect(headerOf(capture.init, SESSION_HEADER)).toBe("ses_relay_1"); + + const { capture: capture2, mockFetch: mockFetch2 } = createCaptureFetch(); + const patched2 = patchFetch(mockFetch2, als, config); + await als.run({ provider: "other", value: "ses_other" }, async () => { + await patched2("https://relay.internal/v1/chat", { method: "POST" }); + }); + expect(headerOf(capture2.init, SESSION_HEADER)).toBeNull(); + }); + + it("supports Request and URL inputs", async () => { + const als = new AsyncLocalStorage(); + const testSession = openCodeSessionIdFor("input-shapes"); + const config = resolveConfig(); + const { capture: c1, mockFetch: m1 } = createCaptureFetch(); + const patched1 = patchFetch(m1, als, config); + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched1( + new Request("https://opencode.ai/zen/v1/responses", { + headers: { "Content-Type": "application/json" }, + method: "POST", + }) + ); + }); + expect(headerOf(c1.init, SESSION_HEADER)).toBe(testSession); + + const { capture: c2, mockFetch: m2 } = createCaptureFetch(); + const patched2 = patchFetch(m2, als, config); + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched2(new URL("https://opencode.ai/zen/v1/responses"), { + method: "POST", + }); + }); + expect(c2.url).toBe("https://opencode.ai/zen/v1/responses"); + expect(headerOf(c2.init, SESSION_HEADER)).toBe(testSession); + }); + + it("keeps a real session header, replaces a malformed one, and honours a pinned env id", async () => { + const als = new AsyncLocalStorage(); + const config = resolveConfig(); + // A real id: ses_ + 12 hex + 14 base62, which is what the client sends. + const real = openCodeSessionIdFor("caller"); + + const { capture: keep, mockFetch: keepFetch } = createCaptureFetch(); + await patchFetch( + keepFetch, + als, + config + )("https://opencode.ai/zen/v1/responses", { + headers: { [SESSION_HEADER]: real }, + }); + expect(headerOf(keep.init, SESSION_HEADER)).toBe(real); + + // A header that merely starts with `ses_` is not a session id. Sending it + // earns a 401 Invalid API key, which reads like a credential fault — so it + // is replaced by a derived id instead. Measured: `ses_dsh_default` is a real + // value a deployment pinned, and the auto-review pass, which runs outside any + // tracked turn, is where it would have been used. + const { capture: malformed, mockFetch: malformedFetch } = + createCaptureFetch(); + await patchFetch( + malformedFetch, + als, + config + )("https://opencode.ai/zen/v1/responses", { + headers: { [SESSION_HEADER]: "ses_dsh_default" }, + }); + const replaced = headerOf(malformed.init, SESSION_HEADER); + expect(replaced).not.toBe("ses_dsh_default"); + expect(replaced).toMatch(/^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/u); + + process.env.OPENCODE_SESSION_ID = PINNED; + const { capture: envCap, mockFetch: envFetch } = createCaptureFetch(); + await patchFetch( + envFetch, + als, + config + )("https://opencode.ai/zen/v1/responses", { + headers: { [SESSION_HEADER]: "bogus" }, + }); + expect(headerOf(envCap.init, SESSION_HEADER)).toBe(PINNED); + }); + + it("respects injectUserAgent false and custom userAgent override", async () => { + const als = new AsyncLocalStorage(); + + const { capture: kept, mockFetch: keptFetch } = createCaptureFetch(); + await als.run({ provider: "opencode", value: "ses_test" }, async () => { + await patchFetch( + keptFetch, + als, + resolveConfig({ injectUserAgent: false }) + )("https://opencode.ai/zen/v1/chat/completions", { + headers: { "User-Agent": "custom-unmodified-ua" }, + method: "POST", + }); + }); + expect(headerOf(kept.init, "User-Agent")).toBe("custom-unmodified-ua"); + expect(headerOf(kept.init, SESSION_HEADER)).toBe("ses_test"); + + const { capture: over, mockFetch: overFetch } = createCaptureFetch(); + await als.run({ provider: "opencode", value: "ses_test" }, async () => { + await patchFetch( + overFetch, + als, + resolveConfig({ + injectUserAgent: true, + userAgent: "my-custom-cli/3.0.0", + }) + )("https://opencode.ai/zen/v1/chat/completions", { method: "POST" }); + }); + expect(headerOf(over.init, "User-Agent")).toBe("my-custom-cli/3.0.0"); + }); + + it("respects injectOriginHeaders false", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch( + mockFetch, + als, + resolveConfig({ injectOriginHeaders: false }) + ); + await als.run({ provider: "opencode", value: "ses_test" }, async () => { + await patched("https://opencode.ai/zen/v1/chat/completions", { + method: "POST", + }); + }); + expect(headerOf(capture.init, "x-opencode-client")).toBeNull(); + expect(headerOf(capture.init, "x-opencode-project")).toBeNull(); + expect(headerOf(capture.init, SESSION_HEADER)).toBe("ses_test"); + }); + + it("injects x-opencode-parent-session-id when parent session is present", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const testSession = openCodeSessionIdFor("child-subagent"); + const testParentSession = openCodeSessionIdFor("parent-lead"); + const patched = patchFetch(mockFetch, als, resolveConfig()); + + await als.run( + { + parentValue: testParentSession, + provider: "opencode", + value: testSession, + }, + async () => { + await patched("https://opencode.ai/zen/v1/chat/completions", { + method: "POST", + }); + } + ); + + // The four session headers are not one value. The client's own source + // (`packages/core/src/session/model-request.ts`) sends the CHILD's id in + // `x-opencode-session-id` and the affinity - `parentID ?? fork ?? id` - in + // the other three, so a subagent identifies itself while its cache grouping + // follows the parent. This plugin used the child's id for all four, which + // put a subagent in its own cache group; the affinity pair belongs to the + // relay branch instead. + expect(headerOf(capture.init, "x-opencode-session-id")).toBe(testSession); + expect(headerOf(capture.init, SESSION_HEADER)).toBe(testSession); + expect(headerOf(capture.init, "x-session-affinity")).toBeNull(); + expect(headerOf(capture.init, SESSION_ID_HEADER)).toBeNull(); + expect(headerOf(capture.init, "x-opencode-parent-session-id")).toBe( + testParentSession + ); + expect(headerOf(capture.init, "x-parent-session-id")).toBe( + testParentSession + ); + }); + + it("sends the affinity pair for a relay and not for an OpenCode plane", async () => { + // The same split 1.19.4 makes, read from + // `packages/opencode/src/session/llm/request.ts:190-202`: for a provider that + // starts with `opencode` the client sends `x-opencode-session`; for anything + // else - a relay a deployment claimed by URL - it sends `x-session-affinity` + // and `X-Session-Id`. This plugin used to send all three everywhere, which is + // neither generation's behaviour. + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch(mockFetch, als, resolveConfig()); + const session = openCodeSessionIdFor("relay-turn"); + + await als.run({ provider: "my-relay", value: session }, async () => { + await patched("https://opencode.ai/zen/v1/chat/completions", { + headers: {}, + method: "POST", + }); + }); + + expect(headerOf(capture.init, "x-opencode-session-id")).toBe(session); + expect(headerOf(capture.init, SESSION_AFFINITY_HEADER)).toBe(session); + expect(headerOf(capture.init, SESSION_ID_HEADER)).toBe(session); + }); + + it("reads the plane from the URL first and the turn's route off a gateway", async () => { + // `route` has two arms. A gateway URL names its plane and wins over the + // turn's provider; a URL that names no plane (a relay the turn's provider + // claims) falls back to the turn's route. + await seedDivergentCatalog(); + const als = new AsyncLocalStorage(); + const config = resolveConfig({ freeModelMarker: "none-of-these" }); + const send = async (provider: string, url: string): Promise => { + const { capture, mockFetch } = createCaptureFetch(); + const value = openCodeSessionIdFor(`route-${provider}-${url}`); + await als.run({ provider, value }, async () => { + await patchFetch( + mockFetch, + als, + config + )(url, { + body: JSON.stringify({ input: "hi", model: GO_FREE_PROBE }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + return toolNamesOf(parseJsonBody(capture.init?.body)); + }; + + // Off a gateway: the turn's route decides. + expect( + await send("opencode-go", "https://relay.example/v1/responses") + ).toEqual(["read", "bash"]); + expect( + await send("opencode", "https://relay.example/v1/responses") + ).toBeUndefined(); + // On a gateway: the URL's plane decides, whatever the turn says. + expect( + await send("opencode", "https://opencode.ai/zen/go/v1/responses") + ).toEqual(["read", "bash"]); + expect( + await send("opencode-go", "https://opencode.ai/zen/v1/responses") + ).toBeUndefined(); + }); + + it("hasSessionHeader answers false for a header list the platform refuses to read", () => { + expect( + hasSessionHeader("https://opencode.ai/zen/v1/responses", { + headers: [["not a header name", "x"]], + }) + ).toBe(false); + }); + + it('sessionIdEnv "" reads no variable: a pinned OPENCODE_SESSION_ID is ignored outside a turn', async () => { + // `resolveConfig` turns a blank name into the default, so the blank reaches + // the patch only as a resolved config handed over directly: the patch still + // reads it as "no variable", never as the empty name. + process.env.OPENCODE_SESSION_ID = PINNED; + const { capture, mockFetch } = createCaptureFetch(); + await patchFetch(mockFetch, new AsyncLocalStorage(), { + ...resolveConfig(), + sessionIdEnv: "", + })("https://opencode.ai/zen/v1/models"); + const sent = headerOf(capture.init, SESSION_HEADER); + expect(sent).not.toBe(PINNED); + expect(isOpenCodeSessionId(sent)).toBe(true); + }); +}); diff --git a/test/fetch-patch-observability.test.ts b/test/fetch-patch-observability.test.ts new file mode 100644 index 00000000..22baf7df --- /dev/null +++ b/test/fetch-patch-observability.test.ts @@ -0,0 +1,264 @@ +/** + * patchFetch · what a claimed request reports, and the answers it passes on. + * + * @module test/fetch-patch-observability.test + */ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + OPENCODE_UA, + SESSION_HEADER, + clearCapturedApiKeys, + getLiveGoCatalog, + isOpenCodeSessionId, + openCodeSessionIdFor, + patchFetch, + resolveConfig, + type ActiveTurnState, +} from "./support/src.ts"; +import { createCaptureFetch } from "./test-helpers.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENCODE_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + delete process.env.OPENCODE_SESSION_ID; + delete process.env.XDG_DATA_HOME; + clearCapturedApiKeys(); +}); + +describe("patchFetch · observability", () => { + it("answers a relay's Go listing with the Go catalog", async () => { + // The listing is claimed against the deployment's own markers. The + // enrichment then re-asked with the DEFAULT markers, so a relay's Go + // listing was filled from the Zen catalog. + const als = new AsyncLocalStorage(); + const upstream = vi.fn(async () => Response.json({ data: [] })); + const patched = patchFetch( + upstream as unknown as typeof fetch, + als, + resolveConfig({ gatewayUrls: ["relay.example/zen"] }), + {} + ); + const response = await patched("https://relay.example/zen/go/v1/models", { + headers: { authorization: "Bearer test-go-key" }, + method: "GET", + }); + const body = (await response.json()) as { data: { id: string }[] }; + const ids = body.data.map((row) => row.id).toSorted(); + expect(ids.length).toBeGreaterThan(0); + expect(ids).toEqual( + getLiveGoCatalog() + .map((spec) => spec.id) + .toSorted() + ); + }); + + it("reports each claimed request to the debug hook, presence not values", async () => { + // The request monitor answers "was this request patched, and how?" — the + // question a run that misbehaves poses and an answer invisible from outside, + // because the gateway's reply for a malformed session id reads exactly like + // its reply for a bad credential. What the interceptor SAW and SENT is + // recorded; the credential itself has no business in a log, so the entry + // carries presence booleans, never the header values. + const als = new AsyncLocalStorage(); + const entries: Record[] = []; + const answering = vi.fn(async () => Response.json({ ok: true })); + const patched = patchFetch( + answering as unknown as typeof fetch, + als, + resolveConfig(), + { + debug: (entry) => { + entries.push(entry); + }, + } + ); + const sessionId = openCodeSessionIdFor("session-debug-1"); + const url = "https://opencode.ai/zen/v1/chat/completions"; + await patched(url, { + body: JSON.stringify({ messages: [] }), + headers: { + Authorization: "Bearer unused", + "User-Agent": "dsh/0.0.0", + "x-opencode-org-id": "wrk_debug", + [SESSION_HEADER]: sessionId, + }, + method: "POST", + }); + expect(entries).toHaveLength(1); + const [entry] = entries; + expect(entry?.auth).toBe(true); + expect(entry?.claimed).toBe(true); + expect(entry?.plane).toBe("opencode"); + expect(entry?.session).toBe(sessionId); + expect(entry?.sessionFormat).toBe(true); + expect(entry?.org).toBe("wrk_debug"); + // What was SENT, not what the caller offered: the patch installs its own + // User-Agent on every claimed request, so that is the value the entry + // records — a caller's UA never reaches the gateway. + expect(entry?.userAgent).toBe(OPENCODE_UA); + expect(entry?.status).toBe(200); + expect(entry?.url).toBe(url); + }); + + it("records an unauthenticated claim as absence, and skips foreign traffic", async () => { + // A claimed request with no credential is the key deployment's normal state, + // and the entry has to say so as absence — false and null, never a value + // that looks like one. What it may NOT do is leave the request bare: a + // session is always injected (derived when the deployment pinned none) and + // the CLI User-Agent always replaces the caller's. A request that is not ours + // is not examined at all, so it produces no entry rather than an empty one. + const als = new AsyncLocalStorage(); + const entries: Record[] = []; + const answering = vi.fn(async () => Response.json({ ok: true })); + const patched = patchFetch( + answering as unknown as typeof fetch, + als, + resolveConfig(), + { + debug: (entry) => { + entries.push(entry); + }, + } + ); + await patched("https://opencode.ai/zen/v1/chat/completions", { + body: JSON.stringify({ messages: [] }), + headers: {}, + method: "POST", + }); + await patched("https://api.deepseek.com/v1/chat/completions", { + body: "{}", + method: "POST", + }); + expect(entries).toHaveLength(1); + const [entry] = entries; + expect(entry?.auth).toBe(false); + expect(entry?.claimed).toBe(true); + expect(entry?.session).toBe(openCodeSessionIdFor("default")); + expect(entry?.sessionFormat).toBe(true); + expect(entry?.org).toBeNull(); + expect(entry?.userAgent).toBe(OPENCODE_UA); + }); + + it("passes a 403 through untouched, message and status", async () => { + // A 403 from this gateway is a refusal it understood, not a rejected + // credential (that is a 401) — but the status is the gateway's to choose and + // this interceptor does not rewrite it. An earlier revision remapped it to + // 400 so DSH's classifier would stop labelling it AUTH; the trade was wrong. + // The classifier's label is its own reading; the message underneath is the + // gateway's and is accurate, and that is what was asked for. + const body = JSON.stringify({ + error: { + message: "OpenCode's free tier can only be used from within OpenCode", + type: "FreeTierError", + }, + }); + const als = new AsyncLocalStorage(); + const patched = patchFetch( + (async () => + new Response(body, { status: 403 })) as unknown as typeof fetch, + als, + resolveConfig() + ); + const response = await patched( + "https://opencode.ai/zen/v1/chat/completions", + { body: "{}", method: "POST" } + ); + expect(response.status).toBe(403); + const parsed = (await response.json()) as { + error: { message: string; type: string }; + }; + expect(parsed.error.type).toBe("FreeTierError"); + expect(parsed.error.message).toContain("from within OpenCode"); + }); + + it("names the tools a request carries for the debug entry, in either nesting", async () => { + // The debug entry answers "which tools did the interceptor see?", because + // the gateway's refusal on a free-tier body is about exactly that. DSH + // sends both nestings - `{ name }` at the top level and `{ function: + // { name } }` - and a body the parser cannot read must be absence, not a + // throw and not a guess. + const als = new AsyncLocalStorage(); + const entries: Record[] = []; + const answering = vi.fn(async () => Response.json({ ok: true })); + const patched = patchFetch( + answering as unknown as typeof fetch, + als, + resolveConfig(), + { + debug: (entry) => { + entries.push(entry); + }, + } + ); + const named = async (body: RequestInit["body"]) => { + await patched("https://opencode.ai/zen/v1/responses", { + body, + method: "POST", + }); + return entries.at(-1)?.toolNames; + }; + + // Both nestings, in one list, in order. + expect( + await named( + JSON.stringify({ + input: "hi", + model: "gpt-5-paid", + tools: [{ name: "read" }, { function: { name: "bash" } }], + }) + ) + ).toBe("read,bash"); + + // Unusable entries are skipped by shape, never invented. + expect( + await named( + JSON.stringify({ + input: "hi", + model: "gpt-5-paid", + tools: [ + "junk", + 7, + null, + { name: 5 }, + { function: null }, + { function: { name: 6 } }, + ], + }) + ) + ).toBe(""); + + // Bodies that are not JSON objects (or not strings at all) are absence too. + expect(await named("42")).toBe(""); + expect(await named("null")).toBe(""); + expect(await named("{not-json")).toBe(""); + expect(await named(Buffer.from("{}", "utf-8"))).toBe(""); + expect(await named("")).toBe(""); + }); + + it("the debug entry carries org, request and user agent as null when the request has none", async () => { + const entries: Record[] = []; + const { mockFetch } = createCaptureFetch(); + await patchFetch( + mockFetch, + new AsyncLocalStorage(), + resolveConfig({ injectUserAgent: false }), + { + debug: (entry) => { + entries.push(entry); + }, + } + )("https://opencode.ai/zen/v1/models"); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + org: null, + request: null, + userAgent: null, + }); + // The session is never null: the patch always sends one. + expect(isOpenCodeSessionId(entries[0]?.session)).toBe(true); + }); +}); diff --git a/test/fetch-patch-tools.test.ts b/test/fetch-patch-tools.test.ts new file mode 100644 index 00000000..f5151b8d --- /dev/null +++ b/test/fetch-patch-tools.test.ts @@ -0,0 +1,375 @@ +/** + * patchFetch · the free-tier core-tool fallback, on every body shape. + * + * @module test/fetch-patch-tools.test + */ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + freeZenModel, + GO_FREE_PROBE, + seedDivergentCatalog, + ZEN_FREE_PROBE, +} from "./support/catalog-fixture.ts"; +import { + DUMMY_BASH_TOOL, + DUMMY_READ_TOOL, + SESSION_HEADER, + clearCapturedApiKeys, + findModelSpecOn, + openCodeSessionIdFor, + patchFetch, + resolveConfig, + type ActiveTurnState, +} from "./support/src.ts"; +import { + createCaptureFetch, + headerOf, + parseJsonBody, + toolNamesOf, +} from "./test-helpers.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENCODE_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + delete process.env.OPENCODE_SESSION_ID; + delete process.env.XDG_DATA_HOME; + clearCapturedApiKeys(); +}); + +describe("patchFetch · core tools", () => { + it("scopes the core-tool fallback to the configured model marker", async () => { + const runWith = async ( + modelName: string, + config: Parameters[0] + ) => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch(mockFetch, als, resolveConfig(config)); + await als.run({ provider: "opencode", value: "ses_marker" }, async () => { + await patched("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ input: "hi", model: modelName }), + method: "POST", + }); + }); + return capture.init?.body; + }; + + // Custom marker: only models carrying it fall back — plus any model the + // catalog calls free, which bypasses the marker entirely. + const customHit = await runWith("acct-preview-9", { + freeModelMarker: "preview", + }); + expect(toolNamesOf(parseJsonBody(customHit))).toEqual(["read", "bash"]); + const customCatalogFree = await runWith(freeZenModel("preview"), { + freeModelMarker: "preview", + }); + expect(toolNamesOf(parseJsonBody(customCatalogFree))).toEqual([ + "read", + "bash", + ]); + const customMiss = await runWith("gpt-5-paid", { + freeModelMarker: "preview", + }); + expect(toolNamesOf(parseJsonBody(customMiss))).toBeUndefined(); + + // `*` applies to every model on the path. + const allModels = await runWith("gpt-5-paid", { freeModelMarker: "*" }); + expect(toolNamesOf(parseJsonBody(allModels))).toEqual(["read", "bash"]); + + // A blank marker falls back to the default instead of disabling injection. + // The id is one no shim carries but that holds the default marker, so only + // the fallback can inject (a catalog-free id would inject regardless), and + // an uncatalogued id without it stays bare, so the default is "free", not + // "*". + const uncatalogued = "acct-preview-free"; + expect(findModelSpecOn("zen", uncatalogued)).toBeUndefined(); + const blank = await runWith(uncatalogued, { freeModelMarker: "" }); + expect(toolNamesOf(parseJsonBody(blank))).toEqual(["read", "bash"]); + const blankMiss = await runWith("gpt-5-paid", { freeModelMarker: "" }); + expect(toolNamesOf(parseJsonBody(blankMiss))).toBeUndefined(); + }); + + it("injects read and bash tools for free-tier /responses models", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const testSession = openCodeSessionIdFor("test-free-turn"); + const patched = patchFetch(mockFetch, als, resolveConfig()); + + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ + input: [{ content: "hi", role: "user" }], + model: "muse-spark-1.2-contributor-free", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + + expect(headerOf(capture.init, "content-length")).not.toBeNull(); + expect(typeof capture.init?.body).toBe("string"); + const names = toolNamesOf(parseJsonBody(capture.init?.body)); + expect(names).toEqual([DUMMY_READ_TOOL.name, DUMMY_BASH_TOOL.name]); + }); + + it("classifies a free model by the plane the request is on", async () => { + // The catalog's free flag is per plane, and the two planes can disagree. + // The seeded probe is free on Zen and paid on Go, under a marker nothing + // matches, so only the plane's own row can admit it: a Zen request gains + // the schemas, the same body on the Go plane is left alone. + await seedDivergentCatalog(); + const als = new AsyncLocalStorage(); + const config = resolveConfig({ freeModelMarker: "none-of-these" }); + const send = async ( + provider: string, + url: string, + model = ZEN_FREE_PROBE + ): Promise => { + const { capture, mockFetch } = createCaptureFetch(); + const value = openCodeSessionIdFor(`plane-${provider}`); + await als.run({ provider, value }, async () => { + await patchFetch( + mockFetch, + als, + config + )(url, { + body: JSON.stringify({ input: "hi", model }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + return toolNamesOf(parseJsonBody(capture.init?.body)); + }; + + expect( + await send("opencode", "https://opencode.ai/zen/v1/responses") + ).toEqual(["read", "bash"]); + expect( + await send("opencode-go", "https://opencode.ai/zen/go/v1/responses") + ).toBeUndefined(); + // The reverse probe, so a Go-first lookup is red in both directions. + expect( + await send( + "opencode-go", + "https://opencode.ai/zen/go/v1/responses", + GO_FREE_PROBE + ) + ).toEqual(["read", "bash"]); + expect( + await send( + "opencode", + "https://opencode.ai/zen/v1/responses", + GO_FREE_PROBE + ) + ).toBeUndefined(); + }); + + it("skips tool injection for paid models, other paths, and invalid JSON", async () => { + const als = new AsyncLocalStorage(); + const testSession = openCodeSessionIdFor("skip-cases"); + const run = async (url: string, body: string) => { + const { capture, mockFetch } = createCaptureFetch(); + await als.run({ provider: "opencode", value: testSession }, async () => { + await patchFetch( + mockFetch, + als, + resolveConfig() + )(url, { + body, + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + return capture.init?.body; + }; + + const paid = await run( + "https://opencode.ai/zen/v1/responses", + JSON.stringify({ input: "hi", model: "gpt-5-paid" }) + ); + expect(toolNamesOf(parseJsonBody(paid))).toBeUndefined(); + + const chat = await run( + "https://opencode.ai/zen/v1/chat/completions", + JSON.stringify({ input: "hi", model: "muse-spark-1.2-contributor-free" }) + ); + // The gateway checks read+bash on completions too, so a free completions + // body gains both; a non-JSON route stays untouched (covered below by the + // models-listing case in tool-fallback tests). + expect(toolNamesOf(parseJsonBody(chat))).toEqual(["read", "bash"]); + + const invalid = await run( + "https://opencode.ai/zen/v1/responses", + "{not-json" + ); + expect(invalid).toBe("{not-json"); + }); + + it("respects injectCoreTools false", async () => { + const als = new AsyncLocalStorage(); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch( + mockFetch, + als, + resolveConfig({ injectCoreTools: false }) + ); + await als.run({ provider: "opencode", value: "ses_test" }, async () => { + await patched("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ + input: [{ content: "hi", role: "user" }], + model: "muse-spark-1.2-contributor-free", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + expect(toolNamesOf(parseJsonBody(capture.init?.body))).toBeUndefined(); + }); + + it("does not duplicate tools and handles Buffer bodies", async () => { + const als = new AsyncLocalStorage(); + const testSession = openCodeSessionIdFor("test-partial-turn"); + const { capture, mockFetch } = createCaptureFetch(); + const patched = patchFetch(mockFetch, als, resolveConfig()); + + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ + input: "run command", + model: "muse-spark-1.2-contributor-free", + tools: [{ name: "bash", type: "function" }], + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + const names = toolNamesOf(parseJsonBody(capture.init?.body)); + expect(names?.filter((n) => n === "bash")).toHaveLength(1); + expect(names?.filter((n) => n === "read")).toHaveLength(1); + + const { capture: bufCap, mockFetch: bufFetch } = createCaptureFetch(); + const bufPatched = patchFetch(bufFetch, als, resolveConfig()); + const payload = Buffer.from( + JSON.stringify({ + input: "test buffer", + model: "muse-spark-1.2-contributor-free", + }), + "utf-8" + ); + await als.run({ provider: "opencode", value: testSession }, async () => { + await bufPatched("https://opencode.ai/zen/v1/responses", { + body: payload, + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + expect(typeof bufCap.init?.body).toBe("string"); + expect(toolNamesOf(parseJsonBody(bufCap.init?.body))).toHaveLength(2); + + const { capture: fullCap, mockFetch: fullFetch } = createCaptureFetch(); + await als.run({ provider: "opencode", value: testSession }, async () => { + await patchFetch( + fullFetch, + als, + resolveConfig() + )("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ + input: "hi", + model: "muse-spark-1.2-contributor-free", + tools: [ + { name: "read", type: "function" }, + { name: "bash", type: "function" }, + ], + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + expect(toolNamesOf(parseJsonBody(fullCap.init?.body))).toHaveLength(2); + }); + + it("sends a Request whose body is already spent, unread and unrebuilt", async () => { + // A caller can hand over a Request it has already read; clone() throws on + // a disturbed stream, and the interceptor must fall back to sending the + // request as it stands - not with a guessed body, and not a dropped one. + const als = new AsyncLocalStorage(); + const seen: unknown[] = []; + const entries: Record[] = []; + const mockFetch = async (input: RequestInfo | URL): Promise => { + seen.push(input); + return new Response("{}", { status: 200 }); + }; + const patched = patchFetch(mockFetch, als, resolveConfig(), { + debug: (entry) => { + entries.push(entry); + }, + }); + const request = new Request("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ model: "muse-spark-1.2-contributor-free" }), + method: "POST", + }); + await request.text(); + + await patched(request); + + expect(seen).toHaveLength(1); + expect(seen[0], "the same object went out, untouched").toBe(request); + expect(entries.at(-1)?.bodySource).toBe("none"); + }); + + it("injects into a Request input by rebuilding it with the same method", async () => { + // The body source the bug was about: DSH sends inference as + // fetch(new Request(url, { body })). Injection has to produce a NEW + // Request - a Request's body cannot be swapped in place - and the patch's + // headers must still ride along in the init. + const als = new AsyncLocalStorage(); + const testSession = openCodeSessionIdFor("request-rebuild"); + const seen: unknown[] = []; + let sentInit: RequestInit | undefined; + const entries: Record[] = []; + const mockFetch = async ( + input: RequestInfo | URL, + init?: RequestInit + ): Promise => { + seen.push(input); + sentInit = init; + return new Response("{}", { status: 200 }); + }; + const patched = patchFetch(mockFetch, als, resolveConfig(), { + debug: (entry) => { + entries.push(entry); + }, + }); + const request = new Request("https://opencode.ai/zen/v1/responses", { + body: JSON.stringify({ + input: "hi", + model: "muse-spark-1.2-contributor-free", + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + + await als.run({ provider: "opencode", value: testSession }, async () => { + await patched(request); + }); + + expect(seen[0]).not.toBe(request); + expect(seen[0]).toBeInstanceOf(Request); + const wire = seen[0] as Request; + expect(wire.method).toBe("POST"); + expect(wire.headers.get("content-type")).toBe("application/json"); + expect(toolNamesOf(parseJsonBody(await wire.text()))).toEqual([ + DUMMY_READ_TOOL.name, + DUMMY_BASH_TOOL.name, + ]); + expect(headerOf(sentInit, SESSION_HEADER)).toBe(testSession); + expect(entries.at(-1)?.bodySource).toBe("request"); + expect(entries.at(-1)?.toolNames).toBe( + [DUMMY_READ_TOOL.name, DUMMY_BASH_TOOL.name].join(",") + ); + }); +}); diff --git a/test/fetch-patch.test.ts b/test/fetch-patch.test.ts index 190d0f65..ad44e64a 100644 --- a/test/fetch-patch.test.ts +++ b/test/fetch-patch.test.ts @@ -1,42 +1,32 @@ /** - * patchFetch. + * patchFetch · the credential a claimed request carries, and the report when + * none resolves. * - * Split out of the former monolithic `plugin.test.ts`. + * Its siblings: `fetch-patch-headers` (the identity and session headers), + * `fetch-patch-tools` (the free-tier core-tool fallback) and + * `fetch-patch-observability` (the debug hook, pass-through answers and the + * listing fill). * * @module test/fetch-patch.test */ - import { AsyncLocalStorage } from "node:async_hooks"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - GO_FREE_PROBE, - seedDivergentCatalog, - ZEN_FREE_PROBE, -} from "./support/catalog-fixture.ts"; +import { freeZenModel, shimFor } from "./support/catalog-fixture.ts"; import { createOpencodeStore } from "./support/opencode-store-fixture.ts"; import { - DUMMY_BASH_TOOL, - DUMMY_READ_TOOL, - OPENCODE_UA, - SESSION_AFFINITY_HEADER, - SESSION_HEADER, clearCapturedApiKeys, getCapturedApiKey, - getLiveGoCatalog, recordCapturedApiKey, - openCodeSessionIdFor, patchFetch, resolveConfig, type ActiveTurnState, } from "./support/src.ts"; -import { - createCaptureFetch, - headerOf, - parseJsonBody, - toolNamesOf, -} from "./test-helpers.ts"; +import { createCaptureFetch, headerOf } from "./test-helpers.ts"; afterEach(() => { vi.unstubAllGlobals(); @@ -47,7 +37,7 @@ afterEach(() => { clearCapturedApiKeys(); }); -describe("patchFetch", () => { +describe("patchFetch · credentials", () => { it("names the sources it consulted when no credential resolves", async () => { // The guard for the tier bug. A mis-classified tier made a captured key // invisible, the adapter's placeholder went out, and the only symptom was a @@ -128,7 +118,7 @@ describe("patchFetch", () => { await patched("https://opencode.ai/zen/v1/chat/completions", { body: JSON.stringify({ messages: [], - model: "beam-free", + model: freeZenModel(), }), headers: {}, method: "POST", @@ -162,7 +152,7 @@ describe("patchFetch", () => { ); await patched( new Request("https://opencode.ai/zen/v1/chat/completions", { - body: JSON.stringify({ messages: [], model: "beam-free" }), + body: JSON.stringify({ messages: [], model: freeZenModel() }), method: "POST", }) ); @@ -189,32 +179,6 @@ describe("patchFetch", () => { } }); - it("answers a relay's Go listing with the Go catalog", async () => { - // The listing is claimed against the deployment's own markers. The - // enrichment then re-asked with the DEFAULT markers, so a relay's Go - // listing was filled from the Zen catalog. - const als = new AsyncLocalStorage(); - const upstream = vi.fn(async () => Response.json({ data: [] })); - const patched = patchFetch( - upstream as unknown as typeof fetch, - als, - resolveConfig({ gatewayUrls: ["relay.example/zen"] }), - {} - ); - const response = await patched("https://relay.example/zen/go/v1/models", { - headers: { authorization: "Bearer test-go-key" }, - method: "GET", - }); - const body = (await response.json()) as { data: { id: string }[] }; - const ids = body.data.map((row) => row.id).toSorted(); - expect(ids.length).toBeGreaterThan(0); - expect(ids).toEqual( - getLiveGoCatalog() - .map((spec) => spec.id) - .toSorted() - ); - }); - it("does not name the sources when a credential does resolve", async () => { const als = new AsyncLocalStorage(); const warnings: unknown[][] = []; @@ -246,93 +210,6 @@ describe("patchFetch", () => { expect(line).toBeUndefined(); }); - it("reports each claimed request to the debug hook, presence not values", async () => { - // The request monitor answers "was this request patched, and how?" — the - // question a run that misbehaves poses and an answer invisible from outside, - // because the gateway's reply for a malformed session id reads exactly like - // its reply for a bad credential. What the interceptor SAW and SENT is - // recorded; the credential itself has no business in a log, so the entry - // carries presence booleans, never the header values. - const als = new AsyncLocalStorage(); - const entries: Record[] = []; - const answering = vi.fn(async () => Response.json({ ok: true })); - const patched = patchFetch( - answering as unknown as typeof fetch, - als, - resolveConfig(), - { - debug: (entry) => { - entries.push(entry); - }, - } - ); - const sessionId = openCodeSessionIdFor("session-debug-1"); - const url = "https://opencode.ai/zen/v1/chat/completions"; - await patched(url, { - body: JSON.stringify({ messages: [] }), - headers: { - Authorization: "Bearer unused", - "User-Agent": "dsh/0.0.0", - "x-opencode-org-id": "wrk_debug", - [SESSION_HEADER]: sessionId, - }, - method: "POST", - }); - expect(entries).toHaveLength(1); - const [entry] = entries; - expect(entry?.auth).toBe(true); - expect(entry?.claimed).toBe(true); - expect(entry?.plane).toBe("opencode"); - expect(entry?.session).toBe(sessionId); - expect(entry?.sessionFormat).toBe(true); - expect(entry?.org).toBe("wrk_debug"); - // What was SENT, not what the caller offered: the patch installs its own - // User-Agent on every claimed request, so that is the value the entry - // records — a caller's UA never reaches the gateway. - expect(entry?.userAgent).toBe(OPENCODE_UA); - expect(entry?.status).toBe(200); - expect(entry?.url).toBe(url); - }); - - it("records an unauthenticated claim as absence, and skips foreign traffic", async () => { - // A claimed request with no credential is the key deployment's normal state, - // and the entry has to say so as absence — false and null, never a value - // that looks like one. What it may NOT do is leave the request bare: a - // session is always injected (derived when the deployment pinned none) and - // the CLI User-Agent always replaces the caller's. A request that is not ours - // is not examined at all, so it produces no entry rather than an empty one. - const als = new AsyncLocalStorage(); - const entries: Record[] = []; - const answering = vi.fn(async () => Response.json({ ok: true })); - const patched = patchFetch( - answering as unknown as typeof fetch, - als, - resolveConfig(), - { - debug: (entry) => { - entries.push(entry); - }, - } - ); - await patched("https://opencode.ai/zen/v1/chat/completions", { - body: JSON.stringify({ messages: [] }), - headers: {}, - method: "POST", - }); - await patched("https://api.deepseek.com/v1/chat/completions", { - body: "{}", - method: "POST", - }); - expect(entries).toHaveLength(1); - const [entry] = entries; - expect(entry?.auth).toBe(false); - expect(entry?.claimed).toBe(true); - expect(entry?.session).toBe(openCodeSessionIdFor("default")); - expect(entry?.sessionFormat).toBe(true); - expect(entry?.org).toBeNull(); - expect(entry?.userAgent).toBe(OPENCODE_UA); - }); - it("carries the credential's org, and nothing when it has none", async () => { // A session token needs the org it belongs to — measured, a paid model // answers `200` with the org and `403` without — while the free tier is @@ -380,803 +257,13 @@ describe("patchFetch", () => { expect(sent.org).toBe("(absent)"); }); - it("passes non-OpenCode requests through completely untouched", async () => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch("upstream-ok"); - - const patched = patchFetch(mockFetch, als, resolveConfig()); - const res = await patched("https://api.deepseek.com/v1/chat/completions", { - body: JSON.stringify({ message: "hello" }), - headers: { "X-Custom-Header": "original" }, - method: "POST", - }); - - expect(await res.text()).toBe("upstream-ok"); - expect(capture.url).toBe("https://api.deepseek.com/v1/chat/completions"); - expect(headerOf(capture.init, "X-Custom-Header")).toBe("original"); - expect(headerOf(capture.init, "User-Agent")).toBeNull(); - expect(headerOf(capture.init, "x-opencode-client")).toBeNull(); - expect(headerOf(capture.init, "x-opencode-project")).toBeNull(); - expect(headerOf(capture.init, SESSION_HEADER)).toBeNull(); - }); - - it("passes a 403 through untouched, message and status", async () => { - // A 403 from this gateway is a refusal it understood, not a rejected - // credential (that is a 401) — but the status is the gateway's to choose and - // this interceptor does not rewrite it. An earlier revision remapped it to - // 400 so DSH's classifier would stop labelling it AUTH; the trade was wrong. - // The classifier's label is its own reading; the message underneath is the - // gateway's and is accurate, and that is what was asked for. - const body = JSON.stringify({ - error: { - message: "OpenCode's free tier can only be used from within OpenCode", - type: "FreeTierError", - }, - }); - const als = new AsyncLocalStorage(); - const patched = patchFetch( - (async () => - new Response(body, { status: 403 })) as unknown as typeof fetch, - als, - resolveConfig() - ); - const response = await patched( - "https://opencode.ai/zen/v1/chat/completions", - { body: "{}", method: "POST" } - ); - expect(response.status).toBe(403); - const parsed = (await response.json()) as { - error: { message: string; type: string }; - }; - expect(parsed.error.type).toBe("FreeTierError"); - expect(parsed.error.message).toContain("from within OpenCode"); - }); - - it("injects origin headers and dynamic session ID for zen requests", async () => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - const testSession = openCodeSessionIdFor("test-dynamic-turn"); - - const patched = patchFetch(mockFetch, als, resolveConfig()); - await als.run({ provider: "opencode", value: testSession }, async () => { - await patched("https://opencode.ai/zen/v1/chat/completions", { - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - }); - - expect(headerOf(capture.init, "User-Agent")).toBe(OPENCODE_UA); - expect(headerOf(capture.init, "x-opencode-client")).toBe("cli"); - expect(headerOf(capture.init, "x-opencode-project")).toBe("global"); - expect(headerOf(capture.init, SESSION_HEADER)).toBe(testSession); - }); - - it("restores configurable origin header values", async () => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - const patched = patchFetch( - mockFetch, - als, - resolveConfig({ originClient: "desktop", injectProject: true }) - ); - - await als.run( - { provider: "opencode", value: "ses_origins_1" }, - async () => { - await patched("https://opencode.ai/zen/v1/chat/completions", { - method: "POST", - }); - } - ); - expect(headerOf(capture.init, "x-opencode-client")).toBe("desktop"); - expect(headerOf(capture.init, "x-opencode-project")).toBe("global"); - }); - - it("dynamically resolves x-opencode-project from workspace when injectProject is on, and omits when off", async () => { - const als = new AsyncLocalStorage(); - const { capture: c1, mockFetch: m1 } = createCaptureFetch(); - const patched1 = patchFetch(m1, als, resolveConfig()); - - // When default (injectProject: true), dynamic project from workspace turn state is used: - await als.run( - { - project: "my-web-workspace", - provider: "opencode", - value: "ses_dyn_proj", - }, - async () => { - await patched1("https://opencode.ai/zen/v1/chat/completions", { - method: "POST", - }); - } - ); - expect(headerOf(c1.init, "x-opencode-project")).toBe("my-web-workspace"); - - // When injectProject: false, header is completely omitted: - const { capture: c2, mockFetch: m2 } = createCaptureFetch(); - const patched2 = patchFetch( - m2, - als, - resolveConfig({ injectProject: false }) - ); - await als.run( - { - project: "my-web-workspace", - provider: "opencode", - value: "ses_dyn_proj", - }, - async () => { - await patched2("https://opencode.ai/zen/v1/chat/completions", { - method: "POST", - }); - } - ); - expect(headerOf(c2.init, "x-opencode-project")).toBeNull(); - }); - - it("scopes the core-tool fallback to the configured model marker", async () => { - const runWith = async ( - modelName: string, - config: Parameters[0] - ) => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - const patched = patchFetch(mockFetch, als, resolveConfig(config)); - await als.run({ provider: "opencode", value: "ses_marker" }, async () => { - await patched("https://opencode.ai/zen/v1/responses", { - body: JSON.stringify({ input: "hi", model: modelName }), - method: "POST", - }); - }); - return capture.init?.body; - }; - - // Custom marker: only models carrying it fall back — plus any model the - // catalog calls free, which bypasses the marker entirely. - const customHit = await runWith("acct-preview-9", { - freeModelMarker: "preview", - }); - expect(toolNamesOf(parseJsonBody(customHit))).toEqual(["read", "bash"]); - const customCatalogFree = await runWith("beam-free", { - freeModelMarker: "preview", - }); - expect(toolNamesOf(parseJsonBody(customCatalogFree))).toEqual([ - "read", - "bash", - ]); - const customMiss = await runWith("gpt-5-paid", { - freeModelMarker: "preview", - }); - expect(toolNamesOf(parseJsonBody(customMiss))).toBeUndefined(); - - // `*` applies to every model on the path. - const allModels = await runWith("gpt-5-paid", { freeModelMarker: "*" }); - expect(toolNamesOf(parseJsonBody(allModels))).toEqual(["read", "bash"]); - - // A blank marker falls back to the default instead of disabling injection. - const blank = await runWith("beam-free", { - freeModelMarker: "", - }); - expect(toolNamesOf(parseJsonBody(blank))).toEqual(["read", "bash"]); - }); - - it("uses the configured session id env outside a turn", async () => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - process.env.MY_SESSION_ID = "ses_eb1e9e706ffeZZZZZZZZZZZZZZ"; - try { - const patched = patchFetch( - mockFetch, - als, - resolveConfig({ sessionIdEnv: "MY_SESSION_ID" }) - ); - await patched("https://opencode.ai/zen/v1/chat/completions", { - method: "POST", - }); - expect(headerOf(capture.init, SESSION_HEADER)).toBe( - "ses_eb1e9e706ffeZZZZZZZZZZZZZZ" - ); - } finally { - delete process.env.MY_SESSION_ID; - } - }); - - it("injects for configured custom relays when turn state matches", async () => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - const config = resolveConfig({ providers: ["my-relay"] }); - const patched = patchFetch(mockFetch, als, config); - - await als.run({ provider: "my-relay", value: "ses_relay_1" }, async () => { - await patched("https://relay.internal/v1/chat", { method: "POST" }); - }); - expect(headerOf(capture.init, SESSION_HEADER)).toBe("ses_relay_1"); - - const { capture: capture2, mockFetch: mockFetch2 } = createCaptureFetch(); - const patched2 = patchFetch(mockFetch2, als, config); - await als.run({ provider: "other", value: "ses_other" }, async () => { - await patched2("https://relay.internal/v1/chat", { method: "POST" }); - }); - expect(headerOf(capture2.init, SESSION_HEADER)).toBeNull(); - }); - - it("supports Request and URL inputs", async () => { - const als = new AsyncLocalStorage(); - const testSession = openCodeSessionIdFor("input-shapes"); - const config = resolveConfig(); - const { capture: c1, mockFetch: m1 } = createCaptureFetch(); - const patched1 = patchFetch(m1, als, config); - await als.run({ provider: "opencode", value: testSession }, async () => { - await patched1( - new Request("https://opencode.ai/zen/v1/responses", { - headers: { "Content-Type": "application/json" }, - method: "POST", - }) - ); - }); - expect(headerOf(c1.init, SESSION_HEADER)).toBe(testSession); - - const { capture: c2, mockFetch: m2 } = createCaptureFetch(); - const patched2 = patchFetch(m2, als, config); - await als.run({ provider: "opencode", value: testSession }, async () => { - await patched2(new URL("https://opencode.ai/zen/v1/responses"), { - method: "POST", - }); - }); - expect(c2.url).toBe("https://opencode.ai/zen/v1/responses"); - expect(headerOf(c2.init, SESSION_HEADER)).toBe(testSession); - }); - - it("keeps a real session header, replaces a malformed one, and honours a pinned env id", async () => { - const als = new AsyncLocalStorage(); - const config = resolveConfig(); - // A real id: ses_ + 12 hex + 14 base62, which is what the client sends. - const real = "ses_ed62aae61ffeA6UwzEFN9YNKk8"; - - const { capture: keep, mockFetch: keepFetch } = createCaptureFetch(); - await patchFetch( - keepFetch, - als, - config - )("https://opencode.ai/zen/v1/responses", { - headers: { [SESSION_HEADER]: real }, - }); - expect(headerOf(keep.init, SESSION_HEADER)).toBe(real); - - // A header that merely starts with `ses_` is not a session id. Sending it - // earns a 401 Invalid API key, which reads like a credential fault — so it - // is replaced by a derived id instead. Measured: `ses_dsh_default` is a real - // value a deployment pinned, and the auto-review pass, which runs outside any - // tracked turn, is where it would have been used. - const { capture: malformed, mockFetch: malformedFetch } = - createCaptureFetch(); - await patchFetch( - malformedFetch, - als, - config - )("https://opencode.ai/zen/v1/responses", { - headers: { [SESSION_HEADER]: "ses_dsh_default" }, - }); - const replaced = headerOf(malformed.init, SESSION_HEADER); - expect(replaced).not.toBe("ses_dsh_default"); - expect(replaced).toMatch(/^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/u); - - process.env.OPENCODE_SESSION_ID = "ses_eb1e9e706ffeZZZZZZZZZZZZZZ"; - const { capture: envCap, mockFetch: envFetch } = createCaptureFetch(); - await patchFetch( - envFetch, - als, - config - )("https://opencode.ai/zen/v1/responses", { - headers: { [SESSION_HEADER]: "bogus" }, - }); - expect(headerOf(envCap.init, SESSION_HEADER)).toBe( - "ses_eb1e9e706ffeZZZZZZZZZZZZZZ" - ); - }); - - it("respects injectUserAgent false and custom userAgent override", async () => { - const als = new AsyncLocalStorage(); - - const { capture: kept, mockFetch: keptFetch } = createCaptureFetch(); - await als.run({ provider: "opencode", value: "ses_test" }, async () => { - await patchFetch( - keptFetch, - als, - resolveConfig({ injectUserAgent: false }) - )("https://opencode.ai/zen/v1/chat/completions", { - headers: { "User-Agent": "custom-unmodified-ua" }, - method: "POST", - }); - }); - expect(headerOf(kept.init, "User-Agent")).toBe("custom-unmodified-ua"); - expect(headerOf(kept.init, SESSION_HEADER)).toBe("ses_test"); - - const { capture: over, mockFetch: overFetch } = createCaptureFetch(); - await als.run({ provider: "opencode", value: "ses_test" }, async () => { - await patchFetch( - overFetch, - als, - resolveConfig({ - injectUserAgent: true, - userAgent: "my-custom-cli/3.0.0", - }) - )("https://opencode.ai/zen/v1/chat/completions", { method: "POST" }); - }); - expect(headerOf(over.init, "User-Agent")).toBe("my-custom-cli/3.0.0"); - }); - - it("respects injectOriginHeaders false", async () => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - const patched = patchFetch( - mockFetch, - als, - resolveConfig({ injectOriginHeaders: false }) - ); - await als.run({ provider: "opencode", value: "ses_test" }, async () => { - await patched("https://opencode.ai/zen/v1/chat/completions", { - method: "POST", - }); - }); - expect(headerOf(capture.init, "x-opencode-client")).toBeNull(); - expect(headerOf(capture.init, "x-opencode-project")).toBeNull(); - expect(headerOf(capture.init, SESSION_HEADER)).toBe("ses_test"); - }); - - it("injects x-opencode-parent-session-id when parent session is present", async () => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - const testSession = openCodeSessionIdFor("child-subagent"); - const testParentSession = openCodeSessionIdFor("parent-lead"); - const patched = patchFetch(mockFetch, als, resolveConfig()); - - await als.run( - { - parentValue: testParentSession, - provider: "opencode", - value: testSession, - }, - async () => { - await patched("https://opencode.ai/zen/v1/chat/completions", { - method: "POST", - }); - } - ); - - // The four session headers are not one value. The client's own source - // (`packages/core/src/session/model-request.ts`) sends the CHILD's id in - // `x-opencode-session-id` and the affinity - `parentID ?? fork ?? id` - in - // the other three, so a subagent identifies itself while its cache grouping - // follows the parent. This plugin used the child's id for all four, which - // put a subagent in its own cache group; the affinity pair belongs to the - // relay branch instead. - expect(headerOf(capture.init, "x-opencode-session-id")).toBe(testSession); - expect(headerOf(capture.init, SESSION_HEADER)).toBe(testSession); - expect(headerOf(capture.init, "x-session-affinity")).toBeNull(); - expect(headerOf(capture.init, "x-session-id")).toBeNull(); - expect(headerOf(capture.init, "x-opencode-parent-session-id")).toBe( - testParentSession - ); - expect(headerOf(capture.init, "x-parent-session-id")).toBe( - testParentSession - ); - }); - - it("sends the affinity pair for a relay and not for an OpenCode plane", async () => { - // The same split 1.19.4 makes, read from - // `packages/opencode/src/session/llm/request.ts:190-202`: for a provider that - // starts with `opencode` the client sends `x-opencode-session`; for anything - // else - a relay a deployment claimed by URL - it sends `x-session-affinity` - // and `X-Session-Id`. This plugin used to send all three everywhere, which is - // neither generation's behaviour. - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - const patched = patchFetch(mockFetch, als, resolveConfig()); - const session = openCodeSessionIdFor("relay-turn"); - - await als.run({ provider: "my-relay", value: session }, async () => { - await patched("https://opencode.ai/zen/v1/chat/completions", { - headers: {}, - method: "POST", - }); - }); - - expect(headerOf(capture.init, "x-opencode-session-id")).toBe(session); - expect(headerOf(capture.init, SESSION_AFFINITY_HEADER)).toBe(session); - expect(headerOf(capture.init, "x-session-id")).toBe(session); - }); - - it("injects read and bash tools for free-tier /responses models", async () => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - const testSession = openCodeSessionIdFor("test-free-turn"); - const patched = patchFetch(mockFetch, als, resolveConfig()); - - await als.run({ provider: "opencode", value: testSession }, async () => { - await patched("https://opencode.ai/zen/v1/responses", { - body: JSON.stringify({ - input: [{ content: "hi", role: "user" }], - model: "muse-spark-1.2-contributor-free", - }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - }); - - expect(headerOf(capture.init, "content-length")).not.toBeNull(); - expect(typeof capture.init?.body).toBe("string"); - const names = toolNamesOf(parseJsonBody(capture.init?.body)); - expect(names).toEqual([DUMMY_READ_TOOL.name, DUMMY_BASH_TOOL.name]); - }); - - it("classifies a free model by the plane the request is on", async () => { - // The catalog's free flag is per plane, and the two planes can disagree. - // The seeded probe is free on Zen and paid on Go, under a marker nothing - // matches, so only the plane's own row can admit it: a Zen request gains - // the schemas, the same body on the Go plane is left alone. - await seedDivergentCatalog(); - const als = new AsyncLocalStorage(); - const config = resolveConfig({ freeModelMarker: "none-of-these" }); - const send = async ( - provider: string, - url: string, - model = ZEN_FREE_PROBE - ): Promise => { - const { capture, mockFetch } = createCaptureFetch(); - const value = openCodeSessionIdFor(`plane-${provider}`); - await als.run({ provider, value }, async () => { - await patchFetch( - mockFetch, - als, - config - )(url, { - body: JSON.stringify({ input: "hi", model }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - }); - return toolNamesOf(parseJsonBody(capture.init?.body)); - }; - - expect( - await send("opencode", "https://opencode.ai/zen/v1/responses") - ).toEqual(["read", "bash"]); - expect( - await send("opencode-go", "https://opencode.ai/zen/go/v1/responses") - ).toBeUndefined(); - // The reverse probe, so a Go-first lookup is red in both directions. - expect( - await send( - "opencode-go", - "https://opencode.ai/zen/go/v1/responses", - GO_FREE_PROBE - ) - ).toEqual(["read", "bash"]); - expect( - await send( - "opencode", - "https://opencode.ai/zen/v1/responses", - GO_FREE_PROBE - ) - ).toBeUndefined(); - }); - - it("reads the plane from the URL first and the turn's route off a gateway", async () => { - // `route` has two arms. A gateway URL names its plane and wins over the - // turn's provider; a URL that names no plane (a relay the turn's provider - // claims) falls back to the turn's route. - await seedDivergentCatalog(); - const als = new AsyncLocalStorage(); - const config = resolveConfig({ freeModelMarker: "none-of-these" }); - const send = async (provider: string, url: string): Promise => { - const { capture, mockFetch } = createCaptureFetch(); - const value = openCodeSessionIdFor(`route-${provider}-${url}`); - await als.run({ provider, value }, async () => { - await patchFetch( - mockFetch, - als, - config - )(url, { - body: JSON.stringify({ input: "hi", model: GO_FREE_PROBE }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - }); - return toolNamesOf(parseJsonBody(capture.init?.body)); - }; - - // Off a gateway: the turn's route decides. - expect( - await send("opencode-go", "https://relay.example/v1/responses") - ).toEqual(["read", "bash"]); - expect( - await send("opencode", "https://relay.example/v1/responses") - ).toBeUndefined(); - // On a gateway: the URL's plane decides, whatever the turn says. - expect( - await send("opencode", "https://opencode.ai/zen/go/v1/responses") - ).toEqual(["read", "bash"]); - expect( - await send("opencode-go", "https://opencode.ai/zen/v1/responses") - ).toBeUndefined(); - }); - - it("skips tool injection for paid models, other paths, and invalid JSON", async () => { - const als = new AsyncLocalStorage(); - const testSession = openCodeSessionIdFor("skip-cases"); - const run = async (url: string, body: string) => { - const { capture, mockFetch } = createCaptureFetch(); - await als.run({ provider: "opencode", value: testSession }, async () => { - await patchFetch( - mockFetch, - als, - resolveConfig() - )(url, { - body, - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - }); - return capture.init?.body; - }; - - const paid = await run( - "https://opencode.ai/zen/v1/responses", - JSON.stringify({ input: "hi", model: "gpt-5-paid" }) - ); - expect(toolNamesOf(parseJsonBody(paid))).toBeUndefined(); - - const chat = await run( - "https://opencode.ai/zen/v1/chat/completions", - JSON.stringify({ input: "hi", model: "muse-spark-1.2-contributor-free" }) - ); - // The gateway checks read+bash on completions too, so a free completions - // body gains both; a non-JSON route stays untouched (covered below by the - // models-listing case in tool-fallback tests). - expect(toolNamesOf(parseJsonBody(chat))).toEqual(["read", "bash"]); - - const invalid = await run( - "https://opencode.ai/zen/v1/responses", - "{not-json" - ); - expect(invalid).toBe("{not-json"); - }); - - it("respects injectCoreTools false", async () => { - const als = new AsyncLocalStorage(); - const { capture, mockFetch } = createCaptureFetch(); - const patched = patchFetch( - mockFetch, - als, - resolveConfig({ injectCoreTools: false }) - ); - await als.run({ provider: "opencode", value: "ses_test" }, async () => { - await patched("https://opencode.ai/zen/v1/responses", { - body: JSON.stringify({ - input: [{ content: "hi", role: "user" }], - model: "muse-spark-1.2-contributor-free", - }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - }); - expect(toolNamesOf(parseJsonBody(capture.init?.body))).toBeUndefined(); - }); - - it("does not duplicate tools and handles Buffer bodies", async () => { - const als = new AsyncLocalStorage(); - const testSession = openCodeSessionIdFor("test-partial-turn"); - const { capture, mockFetch } = createCaptureFetch(); - const patched = patchFetch(mockFetch, als, resolveConfig()); - - await als.run({ provider: "opencode", value: testSession }, async () => { - await patched("https://opencode.ai/zen/v1/responses", { - body: JSON.stringify({ - input: "run command", - model: "muse-spark-1.2-contributor-free", - tools: [{ name: "bash", type: "function" }], - }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - }); - const names = toolNamesOf(parseJsonBody(capture.init?.body)); - expect(names?.filter((n) => n === "bash")).toHaveLength(1); - expect(names?.filter((n) => n === "read")).toHaveLength(1); - - const { capture: bufCap, mockFetch: bufFetch } = createCaptureFetch(); - const bufPatched = patchFetch(bufFetch, als, resolveConfig()); - const payload = Buffer.from( - JSON.stringify({ - input: "test buffer", - model: "muse-spark-1.2-contributor-free", - }), - "utf-8" - ); - await als.run({ provider: "opencode", value: testSession }, async () => { - await bufPatched("https://opencode.ai/zen/v1/responses", { - body: payload, - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - }); - expect(typeof bufCap.init?.body).toBe("string"); - expect(toolNamesOf(parseJsonBody(bufCap.init?.body))).toHaveLength(2); - - const { capture: fullCap, mockFetch: fullFetch } = createCaptureFetch(); - await als.run({ provider: "opencode", value: testSession }, async () => { - await patchFetch( - fullFetch, - als, - resolveConfig() - )("https://opencode.ai/zen/v1/responses", { - body: JSON.stringify({ - input: "hi", - model: "muse-spark-1.2-contributor-free", - tools: [ - { name: "read", type: "function" }, - { name: "bash", type: "function" }, - ], - }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - }); - expect(toolNamesOf(parseJsonBody(fullCap.init?.body))).toHaveLength(2); - }); - - it("names the tools a request carries for the debug entry, in either nesting", async () => { - // The debug entry answers "which tools did the interceptor see?", because - // the gateway's refusal on a free-tier body is about exactly that. DSH - // sends both nestings - `{ name }` at the top level and `{ function: - // { name } }` - and a body the parser cannot read must be absence, not a - // throw and not a guess. - const als = new AsyncLocalStorage(); - const entries: Record[] = []; - const answering = vi.fn(async () => Response.json({ ok: true })); - const patched = patchFetch( - answering as unknown as typeof fetch, - als, - resolveConfig(), - { - debug: (entry) => { - entries.push(entry); - }, - } - ); - const named = async (body: RequestInit["body"]) => { - await patched("https://opencode.ai/zen/v1/responses", { - body, - method: "POST", - }); - return entries.at(-1)?.toolNames; - }; - - // Both nestings, in one list, in order. - expect( - await named( - JSON.stringify({ - input: "hi", - model: "gpt-5-paid", - tools: [{ name: "read" }, { function: { name: "bash" } }], - }) - ) - ).toBe("read,bash"); - - // Unusable entries are skipped by shape, never invented. - expect( - await named( - JSON.stringify({ - input: "hi", - model: "gpt-5-paid", - tools: [ - "junk", - 7, - null, - { name: 5 }, - { function: null }, - { function: { name: 6 } }, - ], - }) - ) - ).toBe(""); - - // Bodies that are not JSON objects (or not strings at all) are absence too. - expect(await named("42")).toBe(""); - expect(await named("null")).toBe(""); - expect(await named("{not-json")).toBe(""); - expect(await named(Buffer.from("{}", "utf-8"))).toBe(""); - expect(await named("")).toBe(""); - }); - - it("sends a Request whose body is already spent, unread and unrebuilt", async () => { - // A caller can hand over a Request it has already read; clone() throws on - // a disturbed stream, and the interceptor must fall back to sending the - // request as it stands - not with a guessed body, and not a dropped one. - const als = new AsyncLocalStorage(); - const seen: unknown[] = []; - const entries: Record[] = []; - const mockFetch = async (input: RequestInfo | URL): Promise => { - seen.push(input); - return new Response("{}", { status: 200 }); - }; - const patched = patchFetch(mockFetch, als, resolveConfig(), { - debug: (entry) => { - entries.push(entry); - }, - }); - const request = new Request("https://opencode.ai/zen/v1/responses", { - body: JSON.stringify({ model: "muse-spark-1.2-contributor-free" }), - method: "POST", - }); - await request.text(); - - await patched(request); - - expect(seen).toHaveLength(1); - expect(seen[0], "the same object went out, untouched").toBe(request); - expect(entries.at(-1)?.bodySource).toBe("none"); - }); - - it("injects into a Request input by rebuilding it with the same method", async () => { - // The body source the bug was about: DSH sends inference as - // fetch(new Request(url, { body })). Injection has to produce a NEW - // Request - a Request's body cannot be swapped in place - and the patch's - // headers must still ride along in the init. - const als = new AsyncLocalStorage(); - const testSession = openCodeSessionIdFor("request-rebuild"); - const seen: unknown[] = []; - let sentInit: RequestInit | undefined; - const entries: Record[] = []; - const mockFetch = async ( - input: RequestInfo | URL, - init?: RequestInit - ): Promise => { - seen.push(input); - sentInit = init; - return new Response("{}", { status: 200 }); - }; - const patched = patchFetch(mockFetch, als, resolveConfig(), { - debug: (entry) => { - entries.push(entry); - }, - }); - const request = new Request("https://opencode.ai/zen/v1/responses", { - body: JSON.stringify({ - input: "hi", - model: "muse-spark-1.2-contributor-free", - }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - - await als.run({ provider: "opencode", value: testSession }, async () => { - await patched(request); - }); - - expect(seen[0]).not.toBe(request); - expect(seen[0]).toBeInstanceOf(Request); - const wire = seen[0] as Request; - expect(wire.method).toBe("POST"); - expect(wire.headers.get("content-type")).toBe("application/json"); - expect(toolNamesOf(parseJsonBody(await wire.text()))).toEqual([ - DUMMY_READ_TOOL.name, - DUMMY_BASH_TOOL.name, - ]); - expect(headerOf(sentInit, SESSION_HEADER)).toBe(testSession); - expect(entries.at(-1)?.bodySource).toBe("request"); - expect(entries.at(-1)?.toolNames).toBe( - [DUMMY_READ_TOOL.name, DUMMY_BASH_TOOL.name].join(",") - ); - }); - - it("fills a captured key for the Zen free model the plane sends on /responses", async () => { - // The reported symptom: a Go-plane free model works, Zen free models do not. - // `muse-spark-1.2-contributor-free` is the ONE Zen free model naming - // `@ai-sdk/openai`, so it is sent on Responses. Before P-R1 that went out - // through a route the plugin mounted itself, carrying pi's `Bearer unused` - // placeholder; a placeholder is still never forwarded (P6 owns the fill). + it("replaces pi's placeholder with the captured Zen key on /responses", async () => { + // The reported symptom was a Zen request on Responses going out with pi's + // `Bearer unused` placeholder, which reads as an invalid key. The fill does + // not look at the model (it reads the request's key, then the captured + // one, then the declared one), so the id below is a neutral uncatalogued + // one: the subject is the placeholder, not the tier. A placeholder is never + // forwarded (P6 owns the fill). // // A key captured from the user's own plane route, on the endpoint family the // catalog now names. @@ -1192,7 +279,7 @@ describe("patchFetch", () => { await als.run({ provider: "opencode", value: "ses_x" }, async () => { await patched("https://opencode.ai/inference/v1/responses", { - body: JSON.stringify({ model: "muse-spark-1.2-contributor-free" }), + body: JSON.stringify({ model: "acct-responses-9" }), // pi's sentinel for a route with no key; a placeholder must be // replaced, never forwarded. headers: { authorization: "Bearer unused" }, @@ -1342,4 +429,106 @@ describe("patchFetch", () => { delete process.env.XDG_DATA_HOME; } }); + + /** + * The warning's report, once per patched fetch and plane. Every case below + * builds its own patch, so the one-per-plane gate never hides a line. + */ + const reportFor = async ( + config: ReturnType, + url: string, + init: RequestInit + ): Promise<{ authorization: string | null; line: string | undefined }> => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { capture, mockFetch } = createCaptureFetch(); + let lines: string[] = []; + try { + await patchFetch( + mockFetch, + new AsyncLocalStorage(), + config + )(url, init); + lines = warn.mock.calls.map((args) => args.map(String).join(" ")); + } finally { + warn.mockRestore(); + } + return { + authorization: headerOf(capture.init, "authorization"), + line: lines.find((text) => text.includes("no credential resolved")), + }; + }; + + /** A paid Zen id, read off the shim rather than named. */ + const paidZenModel = (): string => { + const spec = shimFor("opencode").find((m) => m.is_free !== true); + if (spec === undefined) { + throw new Error("the Zen shim carries no paid model"); + } + return spec.id; + }; + + it("a placeholder in the plane's variable is reported as set, and nothing fills the wire", async () => { + // The variable is SET — to the adapter's placeholder — so the report must + // not send the reader to set it; it says what the variable held, and the + // placeholder is not a credential, so nothing goes out. + process.env.OPENCODE_API_KEY = "unused"; + const { authorization, line } = await reportFor( + resolveConfig(), + "https://opencode.ai/zen/v1/responses", + { body: JSON.stringify({ model: paidZenModel() }), method: "POST" } + ); + expect(authorization).toBeNull(); + expect(line).toContain("OPENCODE_API_KEY: set"); + }); + + it("under the configured policy a captured key never fills the wire, and the report says so", async () => { + const url = "https://opencode.ai/zen/v1/responses"; + recordCapturedApiKey("sk-captured", "opencode", url); + const { authorization, line } = await reportFor( + resolveConfig({ keySource: "configured" }), + url, + { body: JSON.stringify({ model: paidZenModel() }), method: "POST" } + ); + expect(authorization).toBeNull(); + expect(line).toContain( + "a key captured from a live request (policy configured): none" + ); + }); + + it("a Buffer body and a non-JSON body are not free models, so the missing credential is still reported", async () => { + // A body the free-model check cannot read answers "not free": the warning + // is the status quo, never a silence bought by a body it did not parse. + const url = "https://opencode.ai/zen/v1/chat/completions"; + for (const body of [ + Buffer.from(JSON.stringify({ model: freeZenModel() })), + "not json", + "7", + ]) { + const { line } = await reportFor(resolveConfig(), url, { + body: body as BodyInit, + method: "POST", + }); + expect(line, String(body)).toContain( + "no credential resolved for opencode" + ); + } + }); + + it("the report says the installation's store was read when readOpencodeStore is on", async () => { + // Read and found nothing: an empty data directory is a real installation + // state, and the report must say the store was consulted, not skipped. + const empty = mkdtempSync(join(tmpdir(), "dsh-empty-store-")); + process.env.XDG_DATA_HOME = empty; + try { + const { authorization, line } = await reportFor( + resolveConfig({ readOpencodeStore: true }), + "https://opencode.ai/zen/go/v1/chat/completions", + { body: JSON.stringify({ messages: [] }), method: "POST" } + ); + expect(authorization).toBeNull(); + expect(line).toContain("the installation's own store: read"); + } finally { + rmSync(empty, { force: true, recursive: true }); + } + }); }); diff --git a/test/free-tier-contract.test.ts b/test/free-tier-contract.test.ts index 191f6309..a694c088 100644 --- a/test/free-tier-contract.test.ts +++ b/test/free-tier-contract.test.ts @@ -13,11 +13,8 @@ import { describe, expect, it } from "vitest"; -import { - findModelSpecOn, - maybeInjectCoreTools, - OPENCODE_ZEN_CATALOG, -} from "./support/src.ts"; +import { maybeInjectCoreTools, OPENCODE_ZEN_CATALOG } from "./support/src.ts"; +import { parseJsonBody, toolNamesOf } from "./test-helpers.ts"; const ROUTES = [ "https://opencode.ai/zen/v1/chat/completions", @@ -31,19 +28,6 @@ const freeModelIds = (): string[] => { ids.add(spec.id); } } - // The live map keys some ids differently (prefixes). The lookup reads the - // Zen plane, the plane every route below posts to and the one the injector - // reads with no route, so the set and the injector agree on what is free. - for (const id of [ - "mimo-v2.6-flash-free", - "big-pickle", - "beam-free", - "muse-spark-1.2-contributor-free", - ]) { - if (findModelSpecOn("zen", id)?.is_free === true) { - ids.add(id); - } - } return [...ids]; }; @@ -57,27 +41,6 @@ const bodyFor = (model: string): string => stream: true, }); -const toolNamesOf = (body: unknown): string[] => { - try { - const tools: unknown = ( - JSON.parse(body as string) as Record - ).tools; - if (!Array.isArray(tools)) { - return []; - } - return tools.map((t) => { - const rec = t as Record; - if (typeof rec.name === "string") { - return rec.name; - } - const fn = rec.function as Record | undefined; - return typeof fn?.name === "string" ? fn.name : "?"; - }); - } catch { - return []; - } -}; - describe("free-tier contract", () => { const models = freeModelIds(); it("covers at least one catalog free model", () => { @@ -90,7 +53,7 @@ describe("free-tier contract", () => { enabled: true, modelMarker: "free", }); - const names = toolNamesOf(out); + const names = toolNamesOf(parseJsonBody(out)); expect(names).toContain("read"); expect(names).toContain("bash"); }); @@ -104,7 +67,7 @@ describe("free-tier contract", () => { enabled: true, modelMarker: "free", }); - const names = toolNamesOf(out); + const names = toolNamesOf(parseJsonBody(out)); expect(names).toContain("read"); expect(names).toContain("bash"); }); @@ -126,7 +89,4 @@ describe("free-tier contract", () => { }); } } - it("big-pickle is free by catalog flag, not by marker", () => { - expect(findModelSpecOn("zen", "big-pickle")?.is_free).toBe(true); - }); }); diff --git a/test/go-discovery-base-url.test.ts b/test/go-discovery-base-url.test.ts new file mode 100644 index 00000000..364c7a7a --- /dev/null +++ b/test/go-discovery-base-url.test.ts @@ -0,0 +1,143 @@ +/** + * `go-discovery.ts` — how a Zen base URL is rewritten onto the Go plane, and + * which base the meter reads. + * + * @module test/go-discovery-base-url.test + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { + composition, + GO_BASE, + registry, + registryEntry, + ZEN_BASE, +} from "./support/loader-rows.ts"; +import { + DEFAULT_USAGE_BASE_URL, + clearCapturedApiKeys, + resolveGoBaseURL, + toGoBaseURL, +} from "./support/src.ts"; + +/** + * Restore every piece of ambient state these cases touch. + * + * A leaked env var or a leftover captured key silently breaks whichever file + * runs next, so the hook lives in this file and not only in the old monolith. + */ +afterEach(() => { + // Named deletes rather than a loop: the names are the contract. + delete process.env.DSH_TEST_GO_KEY; + delete process.env.OPENCODE_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + clearCapturedApiKeys(); +}); + +describe("toGoBaseURL and resolveGoBaseURL", () => { + it("rewrites a Zen base onto the Go plane", () => { + // The two planes share a host, so the pay-as-you-go base still addresses + // `/usage` once rewritten. Reading the unrewritten base 404s on every poll. + expect(toGoBaseURL(ZEN_BASE)).toBe(GO_BASE); + expect(toGoBaseURL("https://proxy.test/opencode.ai/zen/v1")).toBe( + "https://proxy.test/opencode.ai/zen/go/v1" + ); + }); + + it("rewrites the INFERENCE family onto the Go plane too", () => { + // The catalog now names the inference family for the `opencode` provider + // (`api: https://opencode.ai/inference/openai/v1`), so a base discovered + // from a provider row is very often that family. Rewriting only `zen/v1` + // left it untouched and the meter asked `…/inference/openai/v1/usage` — a + // path that does not exist. Measured: the only public quota route in either + // 1.19.4 or 2.0.32 is `packages/console/app/src/routes/zen/go/v1/usage.ts`, + // and the inference proxy table has no `/inference/.../usage` entry. + expect(toGoBaseURL("https://opencode.ai/inference/openai/v1")).toBe( + GO_BASE + ); + expect(toGoBaseURL("https://opencode.ai/inference/v1")).toBe(GO_BASE); + expect(toGoBaseURL("https://opencode.ai/inference/go/openai/v1")).toBe( + GO_BASE + ); + }); + + it("leaves an already-Go or entirely custom base alone", () => { + // The rewrite is a host-level substitution, not a guess: a self-hosted or + // mirrored gateway must reach its own `/usage`, not the vendor's. + for (const base of [ + GO_BASE, + "https://relay.test/v1", + "https://opencode.ai/api/v1", + "", + ]) { + expect(toGoBaseURL(base)).toBe(base); + } + }); + + it("preserves a trailing slash — normalising it is the service's job", () => { + // This module resolves *which* endpoint; `GoUsageService` appends `/usage` + // and strips the trailing slash itself. Pinning the pass-through here keeps + // the seam honest: a normaliser added later would have to change both. + expect(toGoBaseURL(`${ZEN_BASE}/`)).toBe(`${GO_BASE}/`); + expect(toGoBaseURL("https://relay.test/v1/")).toBe( + "https://relay.test/v1/" + ); + }); + + it("prefers an explicit non-default base URL, verbatim", () => { + // `usageBaseURL` is the one endpoint override a row may set, so it must win + // over anything the composition declares. It is returned unrewritten here; + // the service applies the Zen→Go rewrite afterwards. + const entries: unknown[] = [ + registryEntry("opencode-go", { baseURL: "https://discovered.test/v1" }), + ]; + expect( + resolveGoBaseURL(composition(entries), "https://explicit.test/v1") + ).toBe("https://explicit.test/v1"); + expect( + resolveGoBaseURL(composition(entries), "https://explicit.test/zen/v1") + ).toBe("https://explicit.test/zen/v1"); + }); + + it("falls through to discovery when the configured value is the stock default", () => { + // The default is the absence of an override, not an override: treating it + // as one would pin every user to the vendor endpoint even when their own + // row declares a gateway. + const entries: unknown[] = [ + registryEntry("opencode-go", { baseURL: ZEN_BASE }), + ]; + expect(resolveGoBaseURL(composition(entries), DEFAULT_USAGE_BASE_URL)).toBe( + GO_BASE + ); + }); + + it("returns the configured value when nothing declares an endpoint", () => { + // With no discovery and no override there is nothing to return but what the + // caller passed. `resolveConfig` maps a blank `usageBaseURL` to the stock + // default, so the empty case cannot arrive through the plugin's own config. + const entries: unknown[] = [ + registryEntry("opencode-go", { apiKeyEnv: "GO_REF" }), + ]; + expect(resolveGoBaseURL(composition(entries), DEFAULT_USAGE_BASE_URL)).toBe( + DEFAULT_USAGE_BASE_URL + ); + expect(resolveGoBaseURL(composition(entries), "")).toBe(""); + expect(resolveGoBaseURL(null, DEFAULT_USAGE_BASE_URL)).toBe( + DEFAULT_USAGE_BASE_URL + ); + }); + + it("forwards the targeted route so discovery answers for that row", () => { + // Two rows with different gateways: without the target, discovery would + // read whichever row it reaches first and meter the wrong endpoint. + const entries: unknown[] = [ + registry({ + "my-relay": { baseURL: "https://relay.test/v1" }, + "opencode-go": { baseURL: GO_BASE }, + }), + ]; + expect( + resolveGoBaseURL(composition(entries), DEFAULT_USAGE_BASE_URL, "my-relay") + ).toBe("https://relay.test/v1"); + }); +}); diff --git a/test/go-discovery-keys.test.ts b/test/go-discovery-keys.test.ts new file mode 100644 index 00000000..67d1180e --- /dev/null +++ b/test/go-discovery-keys.test.ts @@ -0,0 +1,593 @@ +/** + * `go-discovery.ts` — the key ladder: which credential source each `keySource` + * policy lets win, which reference a lookup is asked for, the Zen credit + * check, and the gaps characterised while pinning the policy. + * + * Offline and deterministic: every case restores `process.env` and the + * captured-key store in `afterEach`. + * + * @module test/go-discovery-keys.test + */ +import { Context } from "@deepseek-ai/cordis"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + composition, + GO_REQUEST, + registryEntry, + withCredentials, + ZEN_REQUEST, +} from "./support/loader-rows.ts"; +import { + DEFAULT_USAGE_KEY_ENV, + type DiscoveredGoConfig, + KEY_SOURCE_POLICIES, + clearCapturedApiKeys, + discoverGoConfig, + effectiveGoKeyRef, + getCapturedApiKey, + isPlaceholderApiKey, + recordCapturedApiKey, + resolveGoApiKey, + resolveGoKeyForRef, + resolveZenCreditInfo, +} from "./support/src.ts"; + +/** + * Restore every piece of ambient state these cases touch. + * + * A leaked env var or a leftover captured key silently breaks whichever file + * runs next, so the hook lives in this file and not only in the old monolith. + */ +afterEach(() => { + // Named deletes rather than a loop: the names are the contract. + delete process.env.DSH_TEST_GO_KEY; + delete process.env.OPENCODE_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + clearCapturedApiKeys(); +}); + +describe("effectiveGoKeyRef", () => { + it("uses the composition's reference and defaults when there is none", () => { + // The reference is never a row setting: it is the provider row's own + // `apiKeyEnv`, and the built-in default is the fallback. A row that + // declares nothing is exactly a row that never mentioned it. + const entries: unknown[] = [ + registryEntry("opencode-go", { apiKeyEnv: "DSH_TEST_GO_KEY" }), + ]; + expect(effectiveGoKeyRef(discoverGoConfig(composition(entries)))).toBe( + "DSH_TEST_GO_KEY" + ); + expect(effectiveGoKeyRef(discoverGoConfig(composition([])))).toBe( + DEFAULT_USAGE_KEY_ENV + ); + expect(effectiveGoKeyRef({})).toBe(DEFAULT_USAGE_KEY_ENV); + }); + + it("does not mistake a literal key for a reference", () => { + // A row declaring `apiKey` has a secret, not a reference. Reading it as one + // would ask the credentials service for a secret as if it were a name. + const declared: DiscoveredGoConfig = { literalKey: "sk-literal" }; + expect(effectiveGoKeyRef(declared)).toBe(DEFAULT_USAGE_KEY_ENV); + }); + + it("still reaches the default candidate when the reference is blank", async () => { + // `readProviderRow` filters an empty `apiKeyEnv`, but this function takes + // whatever it is handed, so a whitespace reference must not become the only + // candidate: the built-in default is still tried and still answers. + const blank = effectiveGoKeyRef({ keyEnv: " " }); + expect(blank).toBe(" "); + process.env.OPENCODE_GO_API_KEY = "sk-from-default"; + expect(await resolveGoKeyForRef(composition(), blank)).toBe( + "sk-from-default" + ); + }); +}); + +describe("resolveGoApiKey: which source the policy selects", () => { + /** A row declaring a literal key, the source `auto` puts first. */ + const withLiteral = (): unknown => + composition([registryEntry("opencode-go", { apiKey: "sk-literal" })]); + + /** A composition that declares no credential at all. */ + const bare = (): unknown => composition([]); + + it("auto: the composition's literal key wins over a captured key and the env", async () => { + // The default policy is byte-for-byte the original precedence: what the + // composition declares is what the user configured on purpose. + process.env.OPENCODE_GO_API_KEY = "sk-env"; + recordCapturedApiKey("sk-captured", "opencode-go", GO_REQUEST); + expect(await resolveGoApiKey(withLiteral())).toBe("sk-literal"); + }); + + it("request: a captured key is promoted above both declared sources", async () => { + // The whole point of the policy — a rotated live key beats a pinned one. + process.env.OPENCODE_GO_API_KEY = "sk-env"; + recordCapturedApiKey("sk-captured", "opencode-go", GO_REQUEST); + expect(await resolveGoApiKey(withLiteral(), undefined, "request")).toBe( + "sk-captured" + ); + }); + + it("configured: the credentials/env reference beats a captured key", async () => { + // The opposite choice, for a pinned or CI deployment: the declared + // credential is the contract, whatever the last request happened to carry. + process.env.OPENCODE_GO_API_KEY = "sk-env"; + recordCapturedApiKey("sk-captured", "opencode-go", GO_REQUEST); + expect(await resolveGoApiKey(withLiteral(), undefined, "configured")).toBe( + "sk-literal" + ); + expect(await resolveGoApiKey(bare(), undefined, "configured")).toBe( + "sk-env" + ); + expect(await resolveGoApiKey(bare(), undefined, "auto")).toBe( + "sk-captured" + ); + expect(await resolveGoApiKey(bare(), undefined, "request")).toBe( + "sk-captured" + ); + }); + + it("every policy falls back to a key captured under a route it no longer names", async () => { + // A user who renames their Go route would otherwise lose the only working + // key they have: the tier lookup must find it by account, not by route id. + recordCapturedApiKey("sk-rotated", "my-relay", GO_REQUEST); + for (const policy of KEY_SOURCE_POLICIES) { + expect(await resolveGoApiKey(bare(), "opencode-go", policy)).toBe( + "sk-rotated" + ); + } + }); + + it("hands the Go lookup an oc_sk_ key, because the endpoint accepts one", async () => { + // This test used to assert the opposite, on the stated premise that "the Go + // `/usage` endpoint rejects `oc_sk_…` outright". Probed live on 2026-10-08: + // an `oc_sk_…` key answers `/zen/go/v1/usage` with HTTP 200 and real + // windows. The prefix is not a tier marker, so a guard that filtered on it + // discarded the one credential that worked — and the meter, falling through + // to nothing, drew an overflow card with no quota at all. + // + // The endpoint decides entitlement; `usage.ts`'s 403 branch reads that + // answer. Nothing here guesses from the string. + process.env.OPENCODE_GO_API_KEY = "oc_sk_go_capable"; + for (const policy of KEY_SOURCE_POLICIES) { + expect(await resolveGoApiKey(bare(), "opencode-go", policy)).toBe( + "oc_sk_go_capable" + ); + } + // What still keeps a Zen *capture* out of a Go lookup is the TIER it was + // observed on, not its prefix: `ZEN_REQUEST` classifies as `zen`, and + // `tierSatisfies` refuses a zen-tier capture for a go target. That rule + // reads the request, which is evidence, rather than the string, which is + // not. + delete process.env.OPENCODE_GO_API_KEY; + delete process.env.OPENCODE_API_KEY; + recordCapturedApiKey("oc_sk_zen", "opencode", ZEN_REQUEST); + expect(await resolveGoApiKey(bare(), "opencode-go", "configured")).toBe( + undefined + ); + }); + + it("still refuses a placeholder, whatever the prefix", async () => { + // The guard that remains is the one that was always load-bearing: a dummy + // is not a credential. Dropping the prefix test must not drop this. + process.env.OPENCODE_GO_API_KEY = "unused"; + expect( + await resolveGoApiKey(bare(), "opencode-go", "auto") + ).toBeUndefined(); + }); + + it("resolves nothing at all when every source is empty", async () => { + // The caller turns `undefined` into the typed MISSING_CREDENTIAL the client + // renders as "Go is not configured", which is only honest if a genuinely + // absent credential is not dressed up as an empty string. + for (const policy of KEY_SOURCE_POLICIES) { + expect(await resolveGoApiKey(bare(), undefined, policy)).toBeUndefined(); + } + // A row whose only declared field is a blank literal has not declared one. + process.env.OPENCODE_GO_API_KEY = "sk-env"; + const blankLiteral = composition([ + registryEntry("opencode-go", { apiKey: "" }), + ]); + expect(await resolveGoApiKey(blankLiteral)).toBe("sk-env"); + }); + + it("asks the credentials service only when no earlier step answered", async () => { + // A credentials lookup is a host round trip; consulting it behind a + // literal that already answered is a wasted call on every poll, and one + // that would prompt the user for a credential the row already names. + const store = { [DEFAULT_USAGE_KEY_ENV]: "sk-stored" }; + for (const policy of KEY_SOURCE_POLICIES) { + const withLiteralKey = withCredentials(store, [ + registryEntry("opencode-go", { apiKey: "sk-literal" }), + ]); + expect( + await resolveGoApiKey(withLiteralKey.context, undefined, policy) + ).toBe(policy === "request" ? "sk-literal" : "sk-literal"); + expect(withLiteralKey.asked).toEqual([]); + + const withoutLiteral = withCredentials(store, []); + expect( + await resolveGoApiKey(withoutLiteral.context, undefined, policy) + ).toBe("sk-stored"); + // Once, not twice: with no declared `apiKeyEnv` the effective reference IS + // the default, so the old `[ref, DEFAULT]` ladder asked the same question + // twice on every poll. + expect(withoutLiteral.asked).toEqual([DEFAULT_USAGE_KEY_ENV]); + } + }); + + it("asks for the row's declared reference and the built-in default, in that order", async () => { + // The reference is the row's own `apiKeyEnv`; the default is the last + // chance. Which one answered is only visible from the recorded lookups. + const store = { DSH_TEST_GO_KEY: "sk-declared" }; + const declared = withCredentials(store, [ + registryEntry("opencode-go", { apiKeyEnv: "DSH_TEST_GO_KEY" }), + ]); + expect( + await resolveGoApiKey(declared.context, undefined, "configured") + ).toBe("sk-declared"); + expect(declared.asked).toEqual(["DSH_TEST_GO_KEY", DEFAULT_USAGE_KEY_ENV]); + }); +}); + +describe("resolveGoKeyForRef: the reference ladder", () => { + it("never offers the Zen reference to the Go endpoint", async () => { + // Asking for `OPENCODE_API_KEY` collapses to the Go reference alone: the + // Zen secret is never a candidate here, so a caller that mislabels the ref + // still cannot leak the Zen key to `/usage`. + const probe = withCredentials({ + OPENCODE_API_KEY: "oc_sk_zen", + OPENCODE_GO_API_KEY: "sk-go", + }); + expect(await resolveGoKeyForRef(probe.context, "OPENCODE_API_KEY")).toBe( + "sk-go" + ); + expect(probe.asked).toEqual([DEFAULT_USAGE_KEY_ENV]); + }); + + it("takes the declared reference's value even when it is an oc_sk_ key", async () => { + // This used to assert that a stored `oc_sk_…` value was skipped in favour + // of the next candidate. It is not skipped: the prefix does not name a + // tier, and the endpoint accepts these keys (measured, 2026-10-08). The + // declared reference is the one the user wrote down, so it wins. + const probe = withCredentials({ + DSH_TEST_GO_KEY: "oc_sk_zen", + OPENCODE_GO_API_KEY: "sk-go", + }); + expect(await resolveGoKeyForRef(probe.context, "DSH_TEST_GO_KEY")).toBe( + "oc_sk_zen" + ); + expect(probe.asked).toEqual(["DSH_TEST_GO_KEY", DEFAULT_USAGE_KEY_ENV]); + }); + + it("prefers the declared reference over the built-in default", async () => { + // Two stored credentials, one per candidate: the row's own `apiKeyEnv` is + // the reference the user declared, so it must win. + const probe = withCredentials({ + DSH_TEST_GO_KEY: "sk-declared", + OPENCODE_GO_API_KEY: "sk-default", + }); + expect(await resolveGoKeyForRef(probe.context, "DSH_TEST_GO_KEY")).toBe( + "sk-declared" + ); + process.env.OPENCODE_GO_API_KEY = "sk-env"; + expect(await resolveGoKeyForRef(probe.context, "DSH_TEST_GO_KEY")).toBe( + "sk-declared" + ); + }); + + it("prefers a stored credential over a stale exported variable", async () => { + // Every reference is tried through the credentials service before the + // environment is consulted at all, so a rotated stored key is not shadowed + // by the value the shell exported months ago. + process.env.OPENCODE_GO_API_KEY = "sk-stale-export"; + const probe = withCredentials({ [DEFAULT_USAGE_KEY_ENV]: "sk-stored" }); + expect(await resolveGoKeyForRef(probe.context, DEFAULT_USAGE_KEY_ENV)).toBe( + "sk-stored" + ); + expect(probe.asked.length).toBeGreaterThan(0); + }); + + it("falls back to the environment when nothing is stored", async () => { + // The cold-start path, and the one a headless composition lives on: no + // credentials service at all, just an exported variable. + process.env.OPENCODE_GO_API_KEY = "sk-env"; + expect(await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV)).toBe( + "sk-env" + ); + process.env.DSH_TEST_GO_KEY = "sk-declared-env"; + expect(await resolveGoKeyForRef(composition(), "DSH_TEST_GO_KEY")).toBe( + "sk-declared-env" + ); + }); + + it("treats a blank stored value and a blank variable as absent", async () => { + // An empty credential is not a credential: returning it would put + // `Authorization: Bearer ` on the wire and read as a wrong-key 401. + expect( + await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV) + ).toBeUndefined(); + process.env.OPENCODE_GO_API_KEY = ""; + expect( + await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV) + ).toBeUndefined(); + const probe = withCredentials({ [DEFAULT_USAGE_KEY_ENV]: "" }); + expect( + await resolveGoKeyForRef(probe.context, DEFAULT_USAGE_KEY_ENV) + ).toBeUndefined(); + }); + + it("accepts an oc_sk_ key from the environment, and still refuses a dummy", async () => { + // The environment is a store like any other, so it follows the same rule + // the store does: a real credential is handed over whatever its prefix, and + // only a placeholder is refused. + process.env.OPENCODE_GO_API_KEY = "oc_sk_zen"; + expect(await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV)).toBe( + "oc_sk_zen" + ); + + process.env.OPENCODE_GO_API_KEY = "unused"; + expect( + await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV) + ).toBeUndefined(); + }); +}); + +describe("resolveZenCreditInfo", () => { + it("accepts a captured Zen key as configured credit", async () => { + // The captured Zen key is the rotation-proof answer, and it is checked + // before any host lookup so a live turn settles the question immediately. + recordCapturedApiKey("oc_sk_captured", "opencode", ZEN_REQUEST); + const captured = await resolveZenCreditInfo(composition()); + expect(captured.isConfigured).toBe(true); + // Even with nothing in the environment, so the capture really did answer. + const noContext = await resolveZenCreditInfo(null); + expect(noContext.isConfigured).toBe(true); + }); + + it("does not count a Go key as Zen credit", async () => { + // Go credit cannot overflow into Zen: the overflow card is only drawn when + // the Zen plane is the one that would be charged. + recordCapturedApiKey("sk-go", "opencode-go", GO_REQUEST); + const goOnly = await resolveZenCreditInfo(composition()); + expect(goOnly.isConfigured).toBe(false); + }); + + it("reads the Zen reference from the credentials service and the environment", async () => { + // There is no endpoint to ask for a balance, so this answers "can Go + // overflow into Zen credit?" purely from what the composition holds. + const probe = withCredentials({ OPENCODE_API_KEY: "oc_sk_stored" }); + const stored = await resolveZenCreditInfo(probe.context); + expect(stored.isConfigured).toBe(true); + expect(probe.asked).toEqual(["OPENCODE_API_KEY"]); + + process.env.OPENCODE_API_KEY = "oc_sk_env"; + const fromEnv = await resolveZenCreditInfo(composition()); + expect(fromEnv.isConfigured).toBe(true); + }); + + it("reports nothing configured when neither plane holds a Zen credential", async () => { + // The honest negative, so the meter hands over to Go instead of promising + // an overflow card that no balance could ever back. + process.env.OPENCODE_GO_API_KEY = "sk-go"; + const goOnly = await resolveZenCreditInfo(composition()); + expect(goOnly.isConfigured).toBe(false); + const probe = withCredentials({ OPENCODE_API_KEY: "" }); + const blank = await resolveZenCreditInfo(probe.context); + expect(blank.isConfigured).toBe(false); + }); +}); + +describe("gaps characterised while pinning the policy", () => { + it("never lets a placeholder become the credential the meter sends", async () => { + // This repo's README tells users to write exactly `authorization: Bearer + // unused` on a keyless route, and `readProviderRow` used to copy that header + // into `literalKey` with none of the placeholder check + // `recordCapturedApiKey` applies. Because `literal` is the first step of + // both `auto` and `configured`, the sentinel outranked a working stored + // credential AND a working captured key, so the meter authenticated as + // `Bearer unused` on every poll and the 401 read like a missing + // subscription. Fixed by the `usableCredential` guard in `readProviderRow`; + // this now pins the fix rather than the gap. + const context = composition([ + registryEntry("opencode-go", { + headers: { authorization: "Bearer unused" }, + }), + ]); + process.env.OPENCODE_GO_API_KEY = "sk-real-env-key"; + recordCapturedApiKey("sk-real-captured", "opencode-go", GO_REQUEST); + + expect(isPlaceholderApiKey("unused")).toBe(true); + // The sentinel is not a credential at any point of discovery… + expect(discoverGoConfig(context).literalKey).toBeUndefined(); + // …and every policy falls through to a real key, not only the ones that + // happen to rank the captured steps first. Each policy has its own real + // answer, which is the point: `configured` puts the credential store ahead + // of the captured key, so it reaches the env value and not the captured + // one. Before the fix all three answered "unused". + expect(await resolveGoApiKey(context, "opencode-go", "auto")).toBe( + "sk-real-captured" + ); + expect(await resolveGoApiKey(context, "opencode-go", "configured")).toBe( + "sk-real-env-key" + ); + expect(await resolveGoApiKey(context, "opencode-go", "request")).toBe( + "sk-real-captured" + ); + }); + + it("treats the gateway's keyless sentinel as not a credential", async () => { + // A keyless request — no key at all, or the literal `public` — is the + // vendor's own anonymous path: the proxy strips the sentinel before + // forwarding (`packages/console/app/src/lib/inference-proxy.ts`, both 1.19 + // and 2.x: `const anonymous = !key || key === "public"`). + // `docs/upstream-findings.md` §1.5. Old code omitted it, so the literal was + // captured, then forwarded as a real Bearer, turning an anonymous request + // into an authenticated one. + expect(isPlaceholderApiKey("public")).toBe(true); + expect(isPlaceholderApiKey("PUBLIC")).toBe(true); + + // Never captured from a live request, however it arrives. + clearCapturedApiKeys(); + recordCapturedApiKey("public", "opencode-go", GO_REQUEST); + expect(getCapturedApiKey("opencode-go")).toBeUndefined(); + + // And never used as a declared env credential, so a free turn goes out + // keyless rather than carrying the sentinel. + const context = composition([ + registryEntry("opencode-go", { + headers: { authorization: "Bearer public" }, + }), + ]); + process.env.OPENCODE_GO_API_KEY = "public"; + expect( + await resolveGoApiKey(context, "opencode-go", "auto") + ).toBeUndefined(); + expect(discoverGoConfig(context).literalKey).toBeUndefined(); + }); + + it("filters a Zen key out of the Go endpoint however it was filed", async () => { + // The `captured` step is scoped to the route in hand and used to return + // that route's key without consulting the tier, while the declared sources + // reject `oc_sk_…` unconditionally and the any-route captured step asks + // for the Go tier. So the same secret was unusable from every source + // EXCEPT the one that matters most — and it is first in `auto` and + // `request`. Reachable whenever a request left the Go route carrying a Zen + // key (a `OPENCODE_GO_API_KEY` mislabelled to the Zen secret, say). + // + // Fixed in `getCapturedApiKey`: the route-scoped map now stores the tier + // alongside the key and applies the same rule as the other two lookups. + // Naming a route is a PREFERENCE, not a licence to ignore the tier. + // Under any other route id it was always filtered correctly: + clearCapturedApiKeys(); + recordCapturedApiKey("oc_sk_zen", "my-relay", ZEN_REQUEST); + expect( + await resolveGoApiKey(composition(), "opencode-go", "request") + ).toBeUndefined(); + expect( + await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV) + ).toBeUndefined(); + + // Filed under the Go route id it is now filtered identically, so no policy + // can surface it. + clearCapturedApiKeys(); + recordCapturedApiKey("oc_sk_zen", "opencode-go", ZEN_REQUEST); + for (const policy of KEY_SOURCE_POLICIES) { + expect( + await resolveGoApiKey(composition(), "opencode-go", policy), + `policy ${policy} sent a Zen key to the Go endpoint` + ).toBeUndefined(); + } + + // And a correctly-classified key filed under the same route id still + // answers — the filter rejects the wrong tier, not the right one. + clearCapturedApiKeys(); + recordCapturedApiKey("sk-go-key", "opencode-go", GO_REQUEST); + expect(await resolveGoApiKey(composition(), "opencode-go", "auto")).toBe( + "sk-go-key" + ); + }); + + it("never sends a whitespace-only credential to the Go endpoint", async () => { + // CHARACTERISED, NOT ENDORSED. `readProviderRow` and `firstResolved` both + // ask only whether a value is a non-empty *string*, so a row carrying + // `apiKey: " "` — what a YAML value with trailing whitespace parses to — + // declares a credential that is whitespace. It is the first step of `auto` + // and `configured`, so it outranks a working stored credential and the + // meter authenticated as `Bearer ` on every poll. Same class as the + // placeholder gap above — a declared value that is not a credential — and + // fixed by the same `usableCredential` guard. + const context = composition([ + registryEntry("opencode-go", { apiKey: " " }), + ]); + process.env.OPENCODE_GO_API_KEY = "sk-real-env-key"; + recordCapturedApiKey("sk-real-captured", "opencode-go", GO_REQUEST); + + // Same guard as the placeholder above: a blanked-but-indented secret must + // not reach the wire under ANY policy, not only the ones that happen to + // rank the captured steps first. + expect(await resolveGoApiKey(context, "opencode-go", "auto")).toBe( + "sk-real-captured" + ); + expect(await resolveGoApiKey(context, "opencode-go", "configured")).toBe( + "sk-real-env-key" + ); + expect(await resolveGoApiKey(context, "opencode-go", "request")).toBe( + "sk-real-captured" + ); + }); + + it("never asks the credentials service the same reference twice", async () => { + // `resolveGoKeyForRef` builds `[ref, DEFAULT_USAGE_KEY_ENV]` for every + // reference, and `effectiveGoKeyRef` FALLS BACK to the default — so the + // common case, a profile with no declared `apiKeyEnv`, asked the host the + // same question twice on every meter poll. Fixed by deduplicating the + // ladder while keeping its order, because order is the policy. + const probe = withCredentials({ [DEFAULT_USAGE_KEY_ENV]: "sk-stored" }); + expect(await resolveGoApiKey(probe.context, undefined, "configured")).toBe( + "sk-stored" + ); + expect(probe.asked).toEqual([DEFAULT_USAGE_KEY_ENV]); + + // A genuinely different declared reference is still asked FIRST, then the + // default — deduplication must not reorder or drop a real candidate. + const declared = withCredentials({ DSH_TEST_GO_KEY: "sk-declared" }, [ + registryEntry("opencode-go", { apiKeyEnv: "DSH_TEST_GO_KEY" }), + ]); + expect( + await resolveGoApiKey(declared.context, undefined, "configured") + ).toBe("sk-declared"); + expect(declared.asked).toEqual(["DSH_TEST_GO_KEY", DEFAULT_USAGE_KEY_ENV]); + }); +}); + +describe("go-discovery: the key resolution chain, end to end", () => { + /** The row shape a deployment's LLM plugin carries, with both routes named. */ + const llmRow = { + options: { + config: { + providers: { + "opencode-go": { apiKeyEnv: "OPENCODE_GO_API_KEY" }, + opencode: { apiKeyEnv: "OPENCODE_API_KEY" }, + }, + }, + name: "@deepseek-ai/dsh-llm-pi-ai", + }, + }; + + it("finds the Go route's reference and resolves it from the environment", async () => { + // The chain a silent meter would hide: the loader's rows name the route, the + // route names the reference, and the reference has to resolve. Each link is + // asserted, because "no data" says nothing about which one broke. + const ctx = composition([llmRow]); + const discovered = discoverGoConfig(ctx); + expect(effectiveGoKeyRef(discovered)).toBe("OPENCODE_GO_API_KEY"); + + process.env.OPENCODE_GO_API_KEY = "go-key-from-env"; + try { + await expect(resolveGoApiKey(ctx)).resolves.toBe("go-key-from-env"); + } finally { + process.env.OPENCODE_GO_API_KEY = ""; + } + }); + + it("resolves through the credential service when the environment has nothing", async () => { + // The deployment's actual case: the key lives in the credential store, not + // the environment. This is the link that fails silently when the service is + // not reachable from the plugin's context — the environment fallback then + // answers undefined and the meter has nothing to render. + const { asked, context } = withCredentials( + { OPENCODE_GO_API_KEY: "go-key-from-store" }, + [llmRow] + ); + await expect(resolveGoApiKey(context)).resolves.toBe("go-key-from-store"); + expect(asked).toContain("OPENCODE_GO_API_KEY"); + }); + + it("answers nothing rather than throwing when the loader is unreachable", () => { + // A context that serves no loader has no routes to read, and the + // resolution says so instead of failing a request over it. + expect(discoverGoConfig(new Context())).toEqual({}); + }); +}); diff --git a/test/go-discovery-store.test.ts b/test/go-discovery-store.test.ts new file mode 100644 index 00000000..f0d63973 --- /dev/null +++ b/test/go-discovery-store.test.ts @@ -0,0 +1,121 @@ +/** + * `go-discovery.ts` — the installation's own store, and where it sits in the + * key ladder. + * + * @module test/go-discovery-store.test + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { composition, GO_REQUEST } from "./support/loader-rows.ts"; +import { + createOpencodeStore, + type FixtureCredential, + type FixtureStore, +} from "./support/opencode-store-fixture.ts"; +import { + clearCapturedApiKeys, + recordCapturedApiKey, + resolveGoApiKey, + resolveOpencodeStoreKey, +} from "./support/src.ts"; + +/** + * Restore every piece of ambient state these cases touch. + * + * A leaked env var or a leftover captured key silently breaks whichever file + * runs next, so the hook lives in this file and not only in the old monolith. + */ +afterEach(() => { + // Named deletes rather than a loop: the names are the contract. + delete process.env.DSH_TEST_GO_KEY; + delete process.env.OPENCODE_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + clearCapturedApiKeys(); +}); + +describe("go-discovery: the installation's own store, and where it sits", () => { + /** + * The stores this file built, so a failure cannot leave one behind. + * + * `$XDG_DATA_HOME` is what points the reader at a store this suite owns, so + * it is also what a leaked value would use to point every later file at a + * deleted directory. + */ + const stores: FixtureStore[] = []; + + const withStore = ( + credentials: readonly FixtureCredential[] + ): FixtureStore => { + const store = createOpencodeStore(credentials); + stores.push(store); + process.env.XDG_DATA_HOME = store.root; + return store; + }; + + afterEach(() => { + for (const store of stores.splice(0)) { + store.cleanup(); + } + delete process.env.XDG_DATA_HOME; + }); + + it("answers the installation's Go credential when the row opted in", async () => { + withStore([{ integrationId: "opencode-go", value: "sk-fixture-go" }]); + await expect( + resolveGoApiKey(composition(), undefined, "auto", true) + ).resolves.toBe("sk-fixture-go"); + }); + + it("reads nothing while the switch is off, which is the default", async () => { + // The contract, exercised rather than asserted: the same store that answers + // above answers nothing here, so the switch is what decided it. + withStore([{ integrationId: "opencode-go", value: "sk-fixture-go" }]); + await expect(resolveGoApiKey(composition())).resolves.toBeUndefined(); + await expect( + resolveGoApiKey(composition(), undefined, "auto", false) + ).resolves.toBeUndefined(); + await expect( + resolveGoApiKey(composition(), undefined, "request", false) + ).resolves.toBeUndefined(); + }); + + it("still loses to a credential the deployment declared", async () => { + // The rule the whole feature is arranged around: everything the user said + // explicitly outranks anything inferred from another program's files. + withStore([{ integrationId: "opencode-go", value: "sk-fixture-go" }]); + process.env.OPENCODE_GO_API_KEY = "sk-declared-go"; + await expect( + resolveGoApiKey(composition(), undefined, "auto", true) + ).resolves.toBe("sk-declared-go"); + }); + + it("still loses to a key captured from a live request", async () => { + withStore([{ integrationId: "opencode-go", value: "sk-fixture-go" }]); + recordCapturedApiKey("sk-captured-go", "opencode-go", GO_REQUEST); + await expect( + resolveGoApiKey(composition(), undefined, "auto", true) + ).resolves.toBe("sk-captured-go"); + }); + + it("matches the plane's integration rather than any row", async () => { + withStore([{ integrationId: "opencode", value: "oc_sk_fixture_zen" }]); + // The Go lookup asks for the Go integration, so a Zen-only store answers it + // nothing... + await expect( + resolveGoApiKey(composition(), undefined, "auto", true) + ).resolves.toBeUndefined(); + // ...while the Zen integration resolves that same row. The plane is what + // the store is keyed by, and a reader that took the first row would hand + // the Go endpoint a Zen credential. + expect(resolveOpencodeStoreKey("opencode", true)).toBe("oc_sk_fixture_zen"); + }); + + it("answers undefined for a store that holds nothing", async () => { + // An installation with no credentials, and a machine without one at all, + // are the same answer: not an error, and not a request failure. + withStore([]); + await expect( + resolveGoApiKey(composition(), undefined, "auto", true) + ).resolves.toBeUndefined(); + }); +}); diff --git a/test/go-discovery.test.ts b/test/go-discovery.test.ts index 4026620b..caf507b5 100644 --- a/test/go-discovery.test.ts +++ b/test/go-discovery.test.ts @@ -12,9 +12,9 @@ * * The cases are written around the *absences*, because that is where the policy * lives: what each source contributes when it is missing, which reference a - * lookup is actually asked for, and which route id a lookup is scoped to. The - * last describe characterises four gaps found while writing this file — a - * placeholder or whitespace credential discovered as a literal, a Zen key + * lookup is actually asked for, and which route id a lookup is scoped to. + * `go-discovery-keys` characterises four gaps found while writing these cases + * — a placeholder or whitespace credential discovered as a literal, a Zen key * captured under the Go route id, and a duplicated reference lookup — each * characterised rather than endorsed; see the comments for the reason. * @@ -22,44 +22,25 @@ * `process.env` and the captured-key store in `afterEach`, because a leaked env * var silently breaks every file that runs after this one. * + * This file holds which entry and which row field the Go config is read from; + * its siblings hold the base URL (`go-discovery-base-url`), the key ladder + * (`go-discovery-keys`) and the installation's own store + * (`go-discovery-store`). + * * @module test/go-discovery.test */ - -import { afterEach, describe, expect, it, vi } from "vitest"; +import { Context } from "@deepseek-ai/cordis"; +import { afterEach, describe, expect, it } from "vitest"; import { - createOpencodeStore, - type FixtureCredential, - type FixtureStore, -} from "./support/opencode-store-fixture.ts"; -import { - DEFAULT_USAGE_BASE_URL, - DEFAULT_USAGE_KEY_ENV, - type DiscoveredGoConfig, - KEY_SOURCE_POLICIES, - clearCapturedApiKeys, - discoverGoConfig, - effectiveGoKeyRef, - getCapturedApiKey, - isPlaceholderApiKey, - recordCapturedApiKey, - resolveGoApiKey, - resolveGoBaseURL, - resolveGoKeyForRef, - resolveOpencodeStoreKey, - resolveZenCreditInfo, - toGoBaseURL, -} from "./support/src.ts"; -import { createMockContext } from "./test-helpers.ts"; - -/** The Zen plane, which discovery rewrites onto the Go plane. */ -const ZEN_BASE = "https://opencode.ai/zen/v1"; -/** The Go plane every rewrite targets. */ -const GO_BASE = "https://opencode.ai/zen/go/v1"; -/** A request URL that classifies its own tier as Go. */ -const GO_REQUEST = "https://opencode.ai/zen/go/v1/chat/completions"; -/** A request URL that classifies its own tier as Zen. */ -const ZEN_REQUEST = "https://opencode.ai/zen/v1/messages"; + composition, + GO_BASE, + registry, + registryEntry, + standaloneEntry, + ZEN_BASE, +} from "./support/loader-rows.ts"; +import { clearCapturedApiKeys, discoverGoConfig } from "./support/src.ts"; /** * Restore every piece of ambient state these cases touch. @@ -75,67 +56,6 @@ afterEach(() => { clearCapturedApiKeys(); }); -/** A context serving no credentials service: a headless composition. */ -const composition = (entries: readonly unknown[] = []): unknown => ({ - loader: { entries: () => entries }, -}); - -/** One `llm-pi-ai` provider-registry entry carrying a single route row. */ -const registryEntry = ( - routeId: string, - row: Record -): unknown => ({ - options: { config: { providers: { [routeId]: row } } }, -}); - -/** A registry entry declaring several routes at once, whatever shape each is. */ -const registry = (rows: Record): unknown => ({ - options: { config: { providers: rows } }, -}); - -/** A standalone entry (`id: opencode-go`) declaring its config inline. */ -const standaloneEntry = (options: Record): unknown => ({ - options, -}); - -/** The context plus the references the credentials service was asked for. */ -interface CredentialProbe { - /** Every `ref` the lookup passed to `resolve`, in call order. */ - asked: string[]; - context: unknown; -} - -/** - * A context serving a credentials service over `store`, recording each lookup. - * - * The recording is the point: "which reference was asked for" is not observable - * from the returned key alone whenever two candidates resolve to the same - * value, and it is the only way to prove the Zen reference is never offered to - * the Go endpoint. - */ -const withCredentials = ( - store: Record, - entries: readonly unknown[] = [] -): CredentialProbe => { - const asked: string[] = []; - const context: unknown = { - loader: { entries: () => entries }, - get: (name: string): unknown => { - if (name !== "credentials") { - return undefined; - } - return { - resolve: (ref: string): Promise<{ value?: string }> => { - asked.push(ref); - const value = store[ref]; - return Promise.resolve(value === undefined ? {} : { value }); - }, - }; - }, - }; - return { asked, context }; -}; - describe("discoverGoConfig: which entry the credential comes from", () => { it("finds nothing in a context that serves no loader entry list", () => { // The plugin must load against any context shape — a client bundle, a @@ -143,7 +63,7 @@ describe("discoverGoConfig: which entry the credential comes from", () => { // nothing rather than throwing, which is what lets the meter degrade to // "Go is not configured" instead of failing at mount. for (const ctx of [ - createMockContext(), + new Context(), null, undefined, "ctx", @@ -488,752 +408,3 @@ describe("discoverGoConfig: the provider row's own fields", () => { expect(discoverGoConfig(composition(entries))).toEqual({}); }); }); - -describe("toGoBaseURL and resolveGoBaseURL", () => { - it("rewrites a Zen base onto the Go plane", () => { - // The two planes share a host, so the pay-as-you-go base still addresses - // `/usage` once rewritten. Reading the unrewritten base 404s on every poll. - expect(toGoBaseURL(ZEN_BASE)).toBe(GO_BASE); - expect(toGoBaseURL("https://proxy.test/opencode.ai/zen/v1")).toBe( - "https://proxy.test/opencode.ai/zen/go/v1" - ); - }); - - it("rewrites the INFERENCE family onto the Go plane too", () => { - // The catalog now names the inference family for the `opencode` provider - // (`api: https://opencode.ai/inference/openai/v1`), so a base discovered - // from a provider row is very often that family. Rewriting only `zen/v1` - // left it untouched and the meter asked `…/inference/openai/v1/usage` — a - // path that does not exist. Measured: the only public quota route in either - // 1.19.4 or 2.0.32 is `packages/console/app/src/routes/zen/go/v1/usage.ts`, - // and the inference proxy table has no `/inference/.../usage` entry. - expect(toGoBaseURL("https://opencode.ai/inference/openai/v1")).toBe( - GO_BASE - ); - expect(toGoBaseURL("https://opencode.ai/inference/v1")).toBe(GO_BASE); - expect(toGoBaseURL("https://opencode.ai/inference/go/openai/v1")).toBe( - GO_BASE - ); - }); - - it("leaves an already-Go or entirely custom base alone", () => { - // The rewrite is a host-level substitution, not a guess: a self-hosted or - // mirrored gateway must reach its own `/usage`, not the vendor's. - for (const base of [ - GO_BASE, - "https://relay.test/v1", - "https://opencode.ai/api/v1", - "", - ]) { - expect(toGoBaseURL(base)).toBe(base); - } - }); - - it("preserves a trailing slash — normalising it is the service's job", () => { - // This module resolves *which* endpoint; `GoUsageService` appends `/usage` - // and strips the trailing slash itself. Pinning the pass-through here keeps - // the seam honest: a normaliser added later would have to change both. - expect(toGoBaseURL(`${ZEN_BASE}/`)).toBe(`${GO_BASE}/`); - expect(toGoBaseURL("https://relay.test/v1/")).toBe( - "https://relay.test/v1/" - ); - }); - - it("prefers an explicit non-default base URL, verbatim", () => { - // `usageBaseURL` is the one endpoint override a row may set, so it must win - // over anything the composition declares. It is returned unrewritten here; - // the service applies the Zen→Go rewrite afterwards. - const entries: unknown[] = [ - registryEntry("opencode-go", { baseURL: "https://discovered.test/v1" }), - ]; - expect( - resolveGoBaseURL(composition(entries), "https://explicit.test/v1") - ).toBe("https://explicit.test/v1"); - expect( - resolveGoBaseURL(composition(entries), "https://explicit.test/zen/v1") - ).toBe("https://explicit.test/zen/v1"); - }); - - it("falls through to discovery when the configured value is the stock default", () => { - // The default is the absence of an override, not an override: treating it - // as one would pin every user to the vendor endpoint even when their own - // row declares a gateway. - const entries: unknown[] = [ - registryEntry("opencode-go", { baseURL: ZEN_BASE }), - ]; - expect(resolveGoBaseURL(composition(entries), DEFAULT_USAGE_BASE_URL)).toBe( - GO_BASE - ); - }); - - it("returns the configured value when nothing declares an endpoint", () => { - // With no discovery and no override there is nothing to return but what the - // caller passed. `resolveConfig` maps a blank `usageBaseURL` to the stock - // default, so the empty case cannot arrive through the plugin's own config. - const entries: unknown[] = [ - registryEntry("opencode-go", { apiKeyEnv: "GO_REF" }), - ]; - expect(resolveGoBaseURL(composition(entries), DEFAULT_USAGE_BASE_URL)).toBe( - DEFAULT_USAGE_BASE_URL - ); - expect(resolveGoBaseURL(composition(entries), "")).toBe(""); - expect(resolveGoBaseURL(null, DEFAULT_USAGE_BASE_URL)).toBe( - DEFAULT_USAGE_BASE_URL - ); - }); - - it("forwards the targeted route so discovery answers for that row", () => { - // Two rows with different gateways: without the target, discovery would - // read whichever row it reaches first and meter the wrong endpoint. - const entries: unknown[] = [ - registry({ - "my-relay": { baseURL: "https://relay.test/v1" }, - "opencode-go": { baseURL: GO_BASE }, - }), - ]; - expect( - resolveGoBaseURL(composition(entries), DEFAULT_USAGE_BASE_URL, "my-relay") - ).toBe("https://relay.test/v1"); - }); -}); - -describe("effectiveGoKeyRef", () => { - it("uses the composition's reference and defaults when there is none", () => { - // The reference is never a row setting: it is the provider row's own - // `apiKeyEnv`, and the built-in default is the fallback. A row that - // declares nothing is exactly a row that never mentioned it. - const entries: unknown[] = [ - registryEntry("opencode-go", { apiKeyEnv: "DSH_TEST_GO_KEY" }), - ]; - expect(effectiveGoKeyRef(discoverGoConfig(composition(entries)))).toBe( - "DSH_TEST_GO_KEY" - ); - expect(effectiveGoKeyRef(discoverGoConfig(composition([])))).toBe( - DEFAULT_USAGE_KEY_ENV - ); - expect(effectiveGoKeyRef({})).toBe(DEFAULT_USAGE_KEY_ENV); - }); - - it("does not mistake a literal key for a reference", () => { - // A row declaring `apiKey` has a secret, not a reference. Reading it as one - // would ask the credentials service for a secret as if it were a name. - const declared: DiscoveredGoConfig = { literalKey: "sk-literal" }; - expect(effectiveGoKeyRef(declared)).toBe(DEFAULT_USAGE_KEY_ENV); - }); - - it("still reaches the default candidate when the reference is blank", async () => { - // `readProviderRow` filters an empty `apiKeyEnv`, but this function takes - // whatever it is handed, so a whitespace reference must not become the only - // candidate: the built-in default is still tried and still answers. - const blank = effectiveGoKeyRef({ keyEnv: " " }); - expect(blank).toBe(" "); - process.env.OPENCODE_GO_API_KEY = "sk-from-default"; - expect(await resolveGoKeyForRef(composition(), blank)).toBe( - "sk-from-default" - ); - }); -}); - -describe("resolveGoApiKey: which source the policy selects", () => { - /** A row declaring a literal key, the source `auto` puts first. */ - const withLiteral = (): unknown => - composition([registryEntry("opencode-go", { apiKey: "sk-literal" })]); - - /** A composition that declares no credential at all. */ - const bare = (): unknown => composition([]); - - it("auto: the composition's literal key wins over a captured key and the env", async () => { - // The default policy is byte-for-byte the original precedence: what the - // composition declares is what the user configured on purpose. - process.env.OPENCODE_GO_API_KEY = "sk-env"; - recordCapturedApiKey("sk-captured", "opencode-go", GO_REQUEST); - expect(await resolveGoApiKey(withLiteral())).toBe("sk-literal"); - }); - - it("request: a captured key is promoted above both declared sources", async () => { - // The whole point of the policy — a rotated live key beats a pinned one. - process.env.OPENCODE_GO_API_KEY = "sk-env"; - recordCapturedApiKey("sk-captured", "opencode-go", GO_REQUEST); - expect(await resolveGoApiKey(withLiteral(), undefined, "request")).toBe( - "sk-captured" - ); - }); - - it("configured: the credentials/env reference beats a captured key", async () => { - // The opposite choice, for a pinned or CI deployment: the declared - // credential is the contract, whatever the last request happened to carry. - process.env.OPENCODE_GO_API_KEY = "sk-env"; - recordCapturedApiKey("sk-captured", "opencode-go", GO_REQUEST); - expect(await resolveGoApiKey(withLiteral(), undefined, "configured")).toBe( - "sk-literal" - ); - expect(await resolveGoApiKey(bare(), undefined, "configured")).toBe( - "sk-env" - ); - expect(await resolveGoApiKey(bare(), undefined, "auto")).toBe( - "sk-captured" - ); - expect(await resolveGoApiKey(bare(), undefined, "request")).toBe( - "sk-captured" - ); - }); - - it("every policy falls back to a key captured under a route it no longer names", async () => { - // A user who renames their Go route would otherwise lose the only working - // key they have: the tier lookup must find it by account, not by route id. - recordCapturedApiKey("sk-rotated", "my-relay", GO_REQUEST); - for (const policy of KEY_SOURCE_POLICIES) { - expect(await resolveGoApiKey(bare(), "opencode-go", policy)).toBe( - "sk-rotated" - ); - } - }); - - it("hands the Go lookup an oc_sk_ key, because the endpoint accepts one", async () => { - // This test used to assert the opposite, on the stated premise that "the Go - // `/usage` endpoint rejects `oc_sk_…` outright". Probed live on 2026-10-08: - // an `oc_sk_…` key answers `/zen/go/v1/usage` with HTTP 200 and real - // windows. The prefix is not a tier marker, so a guard that filtered on it - // discarded the one credential that worked — and the meter, falling through - // to nothing, drew an overflow card with no quota at all. - // - // The endpoint decides entitlement; `usage.ts`'s 403 branch reads that - // answer. Nothing here guesses from the string. - process.env.OPENCODE_GO_API_KEY = "oc_sk_go_capable"; - for (const policy of KEY_SOURCE_POLICIES) { - expect(await resolveGoApiKey(bare(), "opencode-go", policy)).toBe( - "oc_sk_go_capable" - ); - } - // What still keeps a Zen *capture* out of a Go lookup is the TIER it was - // observed on, not its prefix: `ZEN_REQUEST` classifies as `zen`, and - // `tierSatisfies` refuses a zen-tier capture for a go target. That rule - // reads the request, which is evidence, rather than the string, which is - // not. - delete process.env.OPENCODE_GO_API_KEY; - delete process.env.OPENCODE_API_KEY; - recordCapturedApiKey("oc_sk_zen", "opencode", ZEN_REQUEST); - expect(await resolveGoApiKey(bare(), "opencode-go", "configured")).toBe( - undefined - ); - }); - - it("still refuses a placeholder, whatever the prefix", async () => { - // The guard that remains is the one that was always load-bearing: a dummy - // is not a credential. Dropping the prefix test must not drop this. - process.env.OPENCODE_GO_API_KEY = "unused"; - expect( - await resolveGoApiKey(bare(), "opencode-go", "auto") - ).toBeUndefined(); - }); - - it("resolves nothing at all when every source is empty", async () => { - // The caller turns `undefined` into the typed MISSING_CREDENTIAL the client - // renders as "Go is not configured", which is only honest if a genuinely - // absent credential is not dressed up as an empty string. - for (const policy of KEY_SOURCE_POLICIES) { - expect(await resolveGoApiKey(bare(), undefined, policy)).toBeUndefined(); - } - // A row whose only declared field is a blank literal has not declared one. - process.env.OPENCODE_GO_API_KEY = "sk-env"; - const blankLiteral = composition([ - registryEntry("opencode-go", { apiKey: "" }), - ]); - expect(await resolveGoApiKey(blankLiteral)).toBe("sk-env"); - }); - - it("asks the credentials service only when no earlier step answered", async () => { - // A credentials lookup is a host round trip; consulting it behind a - // literal that already answered is a wasted call on every poll, and one - // that would prompt the user for a credential the row already names. - const store = { [DEFAULT_USAGE_KEY_ENV]: "sk-stored" }; - for (const policy of KEY_SOURCE_POLICIES) { - const withLiteralKey = withCredentials(store, [ - registryEntry("opencode-go", { apiKey: "sk-literal" }), - ]); - expect( - await resolveGoApiKey(withLiteralKey.context, undefined, policy) - ).toBe(policy === "request" ? "sk-literal" : "sk-literal"); - expect(withLiteralKey.asked).toEqual([]); - - const withoutLiteral = withCredentials(store, []); - expect( - await resolveGoApiKey(withoutLiteral.context, undefined, policy) - ).toBe("sk-stored"); - // Once, not twice: with no declared `apiKeyEnv` the effective reference IS - // the default, so the old `[ref, DEFAULT]` ladder asked the same question - // twice on every poll. - expect(withoutLiteral.asked).toEqual([DEFAULT_USAGE_KEY_ENV]); - } - }); - - it("asks for the row's declared reference and the built-in default, in that order", async () => { - // The reference is the row's own `apiKeyEnv`; the default is the last - // chance. Which one answered is only visible from the recorded lookups. - const store = { DSH_TEST_GO_KEY: "sk-declared" }; - const declared = withCredentials(store, [ - registryEntry("opencode-go", { apiKeyEnv: "DSH_TEST_GO_KEY" }), - ]); - expect( - await resolveGoApiKey(declared.context, undefined, "configured") - ).toBe("sk-declared"); - expect(declared.asked).toEqual(["DSH_TEST_GO_KEY", DEFAULT_USAGE_KEY_ENV]); - }); -}); - -describe("resolveGoKeyForRef: the reference ladder", () => { - it("never offers the Zen reference to the Go endpoint", async () => { - // Asking for `OPENCODE_API_KEY` collapses to the Go reference alone: the - // Zen secret is never a candidate here, so a caller that mislabels the ref - // still cannot leak the Zen key to `/usage`. - const probe = withCredentials({ - OPENCODE_API_KEY: "oc_sk_zen", - OPENCODE_GO_API_KEY: "sk-go", - }); - expect(await resolveGoKeyForRef(probe.context, "OPENCODE_API_KEY")).toBe( - "sk-go" - ); - expect(probe.asked).toEqual([DEFAULT_USAGE_KEY_ENV]); - }); - - it("takes the declared reference's value even when it is an oc_sk_ key", async () => { - // This used to assert that a stored `oc_sk_…` value was skipped in favour - // of the next candidate. It is not skipped: the prefix does not name a - // tier, and the endpoint accepts these keys (measured, 2026-10-08). The - // declared reference is the one the user wrote down, so it wins. - const probe = withCredentials({ - DSH_TEST_GO_KEY: "oc_sk_zen", - OPENCODE_GO_API_KEY: "sk-go", - }); - expect(await resolveGoKeyForRef(probe.context, "DSH_TEST_GO_KEY")).toBe( - "oc_sk_zen" - ); - expect(probe.asked).toEqual(["DSH_TEST_GO_KEY", DEFAULT_USAGE_KEY_ENV]); - }); - - it("prefers the declared reference over the built-in default", async () => { - // Two stored credentials, one per candidate: the row's own `apiKeyEnv` is - // the reference the user declared, so it must win. - const probe = withCredentials({ - DSH_TEST_GO_KEY: "sk-declared", - OPENCODE_GO_API_KEY: "sk-default", - }); - expect(await resolveGoKeyForRef(probe.context, "DSH_TEST_GO_KEY")).toBe( - "sk-declared" - ); - process.env.OPENCODE_GO_API_KEY = "sk-env"; - expect(await resolveGoKeyForRef(probe.context, "DSH_TEST_GO_KEY")).toBe( - "sk-declared" - ); - }); - - it("prefers a stored credential over a stale exported variable", async () => { - // Every reference is tried through the credentials service before the - // environment is consulted at all, so a rotated stored key is not shadowed - // by the value the shell exported months ago. - process.env.OPENCODE_GO_API_KEY = "sk-stale-export"; - const probe = withCredentials({ [DEFAULT_USAGE_KEY_ENV]: "sk-stored" }); - expect(await resolveGoKeyForRef(probe.context, DEFAULT_USAGE_KEY_ENV)).toBe( - "sk-stored" - ); - expect(probe.asked.length).toBeGreaterThan(0); - }); - - it("falls back to the environment when nothing is stored", async () => { - // The cold-start path, and the one a headless composition lives on: no - // credentials service at all, just an exported variable. - process.env.OPENCODE_GO_API_KEY = "sk-env"; - expect(await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV)).toBe( - "sk-env" - ); - process.env.DSH_TEST_GO_KEY = "sk-declared-env"; - expect(await resolveGoKeyForRef(composition(), "DSH_TEST_GO_KEY")).toBe( - "sk-declared-env" - ); - }); - - it("treats a blank stored value and a blank variable as absent", async () => { - // An empty credential is not a credential: returning it would put - // `Authorization: Bearer ` on the wire and read as a wrong-key 401. - expect( - await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV) - ).toBeUndefined(); - process.env.OPENCODE_GO_API_KEY = ""; - expect( - await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV) - ).toBeUndefined(); - const probe = withCredentials({ [DEFAULT_USAGE_KEY_ENV]: "" }); - expect( - await resolveGoKeyForRef(probe.context, DEFAULT_USAGE_KEY_ENV) - ).toBeUndefined(); - }); - - it("accepts an oc_sk_ key from the environment, and still refuses a dummy", async () => { - // The environment is a store like any other, so it follows the same rule - // the store does: a real credential is handed over whatever its prefix, and - // only a placeholder is refused. - process.env.OPENCODE_GO_API_KEY = "oc_sk_zen"; - expect(await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV)).toBe( - "oc_sk_zen" - ); - - process.env.OPENCODE_GO_API_KEY = "unused"; - expect( - await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV) - ).toBeUndefined(); - }); -}); - -describe("resolveZenCreditInfo", () => { - it("accepts a captured Zen key as configured credit", async () => { - // The captured Zen key is the rotation-proof answer, and it is checked - // before any host lookup so a live turn settles the question immediately. - recordCapturedApiKey("oc_sk_captured", "opencode", ZEN_REQUEST); - const captured = await resolveZenCreditInfo(composition()); - expect(captured.isConfigured).toBe(true); - // Even with nothing in the environment, so the capture really did answer. - const noContext = await resolveZenCreditInfo(null); - expect(noContext.isConfigured).toBe(true); - }); - - it("does not count a Go key as Zen credit", async () => { - // Go credit cannot overflow into Zen: the overflow card is only drawn when - // the Zen plane is the one that would be charged. - recordCapturedApiKey("sk-go", "opencode-go", GO_REQUEST); - const goOnly = await resolveZenCreditInfo(composition()); - expect(goOnly.isConfigured).toBe(false); - }); - - it("reads the Zen reference from the credentials service and the environment", async () => { - // There is no endpoint to ask for a balance, so this answers "can Go - // overflow into Zen credit?" purely from what the composition holds. - const probe = withCredentials({ OPENCODE_API_KEY: "oc_sk_stored" }); - const stored = await resolveZenCreditInfo(probe.context); - expect(stored.isConfigured).toBe(true); - expect(probe.asked).toEqual(["OPENCODE_API_KEY"]); - - process.env.OPENCODE_API_KEY = "oc_sk_env"; - const fromEnv = await resolveZenCreditInfo(composition()); - expect(fromEnv.isConfigured).toBe(true); - }); - - it("reports nothing configured when neither plane holds a Zen credential", async () => { - // The honest negative, so the meter hands over to Go instead of promising - // an overflow card that no balance could ever back. - process.env.OPENCODE_GO_API_KEY = "sk-go"; - const goOnly = await resolveZenCreditInfo(composition()); - expect(goOnly.isConfigured).toBe(false); - const probe = withCredentials({ OPENCODE_API_KEY: "" }); - const blank = await resolveZenCreditInfo(probe.context); - expect(blank.isConfigured).toBe(false); - }); -}); - -describe("gaps characterised while pinning the policy", () => { - it("never lets a placeholder become the credential the meter sends", async () => { - // This repo's README tells users to write exactly `authorization: Bearer - // unused` on a keyless route, and `readProviderRow` used to copy that header - // into `literalKey` with none of the placeholder check - // `recordCapturedApiKey` applies. Because `literal` is the first step of - // both `auto` and `configured`, the sentinel outranked a working stored - // credential AND a working captured key, so the meter authenticated as - // `Bearer unused` on every poll and the 401 read like a missing - // subscription. Fixed by the `usableCredential` guard in `readProviderRow`; - // this now pins the fix rather than the gap. - const context = composition([ - registryEntry("opencode-go", { - headers: { authorization: "Bearer unused" }, - }), - ]); - process.env.OPENCODE_GO_API_KEY = "sk-real-env-key"; - recordCapturedApiKey("sk-real-captured", "opencode-go", GO_REQUEST); - - expect(isPlaceholderApiKey("unused")).toBe(true); - // The sentinel is not a credential at any point of discovery… - expect(discoverGoConfig(context).literalKey).toBeUndefined(); - // …and every policy falls through to a real key, not only the ones that - // happen to rank the captured steps first. Each policy has its own real - // answer, which is the point: `configured` puts the credential store ahead - // of the captured key, so it reaches the env value and not the captured - // one. Before the fix all three answered "unused". - expect(await resolveGoApiKey(context, "opencode-go", "auto")).toBe( - "sk-real-captured" - ); - expect(await resolveGoApiKey(context, "opencode-go", "configured")).toBe( - "sk-real-env-key" - ); - expect(await resolveGoApiKey(context, "opencode-go", "request")).toBe( - "sk-real-captured" - ); - }); - - it("treats the gateway's keyless sentinel as not a credential", async () => { - // A keyless request — no key at all, or the literal `public` — is the - // vendor's own anonymous path: the proxy strips the sentinel before - // forwarding (`packages/console/app/src/lib/inference-proxy.ts`, both 1.19 - // and 2.x: `const anonymous = !key || key === "public"`). - // `docs/upstream-findings.md` §1.5. Old code omitted it, so the literal was - // captured, then forwarded as a real Bearer, turning an anonymous request - // into an authenticated one. - expect(isPlaceholderApiKey("public")).toBe(true); - expect(isPlaceholderApiKey("PUBLIC")).toBe(true); - - // Never captured from a live request, however it arrives. - clearCapturedApiKeys(); - recordCapturedApiKey("public", "opencode-go", GO_REQUEST); - expect(getCapturedApiKey("opencode-go")).toBeUndefined(); - - // And never used as a declared env credential, so a free turn goes out - // keyless rather than carrying the sentinel. - const context = composition([ - registryEntry("opencode-go", { - headers: { authorization: "Bearer public" }, - }), - ]); - process.env.OPENCODE_GO_API_KEY = "public"; - expect( - await resolveGoApiKey(context, "opencode-go", "auto") - ).toBeUndefined(); - expect(discoverGoConfig(context).literalKey).toBeUndefined(); - }); - - it("filters a Zen key out of the Go endpoint however it was filed", async () => { - // The `captured` step is scoped to the route in hand and used to return - // that route's key without consulting the tier, while the declared sources - // reject `oc_sk_…` unconditionally and the any-route captured step asks - // for the Go tier. So the same secret was unusable from every source - // EXCEPT the one that matters most — and it is first in `auto` and - // `request`. Reachable whenever a request left the Go route carrying a Zen - // key (a `OPENCODE_GO_API_KEY` mislabelled to the Zen secret, say). - // - // Fixed in `getCapturedApiKey`: the route-scoped map now stores the tier - // alongside the key and applies the same rule as the other two lookups. - // Naming a route is a PREFERENCE, not a licence to ignore the tier. - // Under any other route id it was always filtered correctly: - clearCapturedApiKeys(); - recordCapturedApiKey("oc_sk_zen", "my-relay", ZEN_REQUEST); - expect( - await resolveGoApiKey(composition(), "opencode-go", "request") - ).toBeUndefined(); - expect( - await resolveGoKeyForRef(composition(), DEFAULT_USAGE_KEY_ENV) - ).toBeUndefined(); - - // Filed under the Go route id it is now filtered identically, so no policy - // can surface it. - clearCapturedApiKeys(); - recordCapturedApiKey("oc_sk_zen", "opencode-go", ZEN_REQUEST); - for (const policy of KEY_SOURCE_POLICIES) { - expect( - await resolveGoApiKey(composition(), "opencode-go", policy), - `policy ${policy} sent a Zen key to the Go endpoint` - ).toBeUndefined(); - } - - // And a correctly-classified key filed under the same route id still - // answers — the filter rejects the wrong tier, not the right one. - clearCapturedApiKeys(); - recordCapturedApiKey("sk-go-key", "opencode-go", GO_REQUEST); - expect(await resolveGoApiKey(composition(), "opencode-go", "auto")).toBe( - "sk-go-key" - ); - }); - - it("never sends a whitespace-only credential to the Go endpoint", async () => { - // CHARACTERISED, NOT ENDORSED. `readProviderRow` and `firstResolved` both - // ask only whether a value is a non-empty *string*, so a row carrying - // `apiKey: " "` — what a YAML value with trailing whitespace parses to — - // declares a credential that is whitespace. It is the first step of `auto` - // and `configured`, so it outranks a working stored credential and the - // meter authenticated as `Bearer ` on every poll. Same class as the - // placeholder gap above — a declared value that is not a credential — and - // fixed by the same `usableCredential` guard. - const context = composition([ - registryEntry("opencode-go", { apiKey: " " }), - ]); - process.env.OPENCODE_GO_API_KEY = "sk-real-env-key"; - recordCapturedApiKey("sk-real-captured", "opencode-go", GO_REQUEST); - - // Same guard as the placeholder above: a blanked-but-indented secret must - // not reach the wire under ANY policy, not only the ones that happen to - // rank the captured steps first. - expect(await resolveGoApiKey(context, "opencode-go", "auto")).toBe( - "sk-real-captured" - ); - expect(await resolveGoApiKey(context, "opencode-go", "configured")).toBe( - "sk-real-env-key" - ); - expect(await resolveGoApiKey(context, "opencode-go", "request")).toBe( - "sk-real-captured" - ); - }); - - it("never asks the credentials service the same reference twice", async () => { - // `resolveGoKeyForRef` builds `[ref, DEFAULT_USAGE_KEY_ENV]` for every - // reference, and `effectiveGoKeyRef` FALLS BACK to the default — so the - // common case, a profile with no declared `apiKeyEnv`, asked the host the - // same question twice on every meter poll. Fixed by deduplicating the - // ladder while keeping its order, because order is the policy. - const probe = withCredentials({ [DEFAULT_USAGE_KEY_ENV]: "sk-stored" }); - expect(await resolveGoApiKey(probe.context, undefined, "configured")).toBe( - "sk-stored" - ); - expect(probe.asked).toEqual([DEFAULT_USAGE_KEY_ENV]); - - // A genuinely different declared reference is still asked FIRST, then the - // default — deduplication must not reorder or drop a real candidate. - const declared = withCredentials({ DSH_TEST_GO_KEY: "sk-declared" }, [ - registryEntry("opencode-go", { apiKeyEnv: "DSH_TEST_GO_KEY" }), - ]); - expect( - await resolveGoApiKey(declared.context, undefined, "configured") - ).toBe("sk-declared"); - expect(declared.asked).toEqual(["DSH_TEST_GO_KEY", DEFAULT_USAGE_KEY_ENV]); - }); -}); - -describe("go-discovery: the key resolution chain, end to end", () => { - /** The row shape a deployment's LLM plugin carries, with both routes named. */ - const llmRow = { - options: { - config: { - providers: { - "opencode-go": { apiKeyEnv: "OPENCODE_GO_API_KEY" }, - opencode: { apiKeyEnv: "OPENCODE_API_KEY" }, - }, - }, - name: "@deepseek-ai/dsh-llm-pi-ai", - }, - }; - - it("finds the Go route's reference and resolves it from the environment", async () => { - // The chain a silent meter would hide: the loader's rows name the route, the - // route names the reference, and the reference has to resolve. Each link is - // asserted, because "no data" says nothing about which one broke. - const ctx = { get: vi.fn(), loader: { entries: () => [llmRow] } }; - const discovered = discoverGoConfig(ctx); - expect(effectiveGoKeyRef(discovered)).toBe("OPENCODE_GO_API_KEY"); - - process.env.OPENCODE_GO_API_KEY = "go-key-from-env"; - try { - await expect(resolveGoApiKey(ctx)).resolves.toBe("go-key-from-env"); - } finally { - process.env.OPENCODE_GO_API_KEY = ""; - } - }); - - it("resolves through the credential service when the environment has nothing", async () => { - // The deployment's actual case: the key lives in the credential store, not - // the environment. This is the link that fails silently when the service is - // not reachable from the plugin's context — the environment fallback then - // answers undefined and the meter has nothing to render. - const asked: string[] = []; - const ctx = { - get: (name: string) => - name === "credentials" - ? { - resolve: (ref: string) => { - asked.push(ref); - return Promise.resolve({ value: "go-key-from-store" }); - }, - } - : undefined, - loader: { entries: () => [llmRow] }, - }; - await expect(resolveGoApiKey(ctx)).resolves.toBe("go-key-from-store"); - expect(asked).toContain("OPENCODE_GO_API_KEY"); - }); - - it("answers nothing rather than throwing when the loader is unreachable", () => { - // A composition whose context cannot see the loader has no routes to read, - // and the resolution says so instead of failing a request over it. - expect(discoverGoConfig({ get: vi.fn() })).toEqual({}); - }); -}); - -describe("go-discovery: the installation's own store, and where it sits", () => { - /** - * The stores this file built, so a failure cannot leave one behind. - * - * `$XDG_DATA_HOME` is what points the reader at a store this suite owns, so - * it is also what a leaked value would use to point every later file at a - * deleted directory. - */ - const stores: FixtureStore[] = []; - - const withStore = ( - credentials: readonly FixtureCredential[] - ): FixtureStore => { - const store = createOpencodeStore(credentials); - stores.push(store); - process.env.XDG_DATA_HOME = store.root; - return store; - }; - - afterEach(() => { - for (const store of stores.splice(0)) { - store.cleanup(); - } - delete process.env.XDG_DATA_HOME; - }); - - it("answers the installation's Go credential when the row opted in", async () => { - withStore([{ integrationId: "opencode-go", value: "sk-fixture-go" }]); - await expect( - resolveGoApiKey(composition(), undefined, "auto", true) - ).resolves.toBe("sk-fixture-go"); - }); - - it("reads nothing while the switch is off, which is the default", async () => { - // The contract, exercised rather than asserted: the same store that answers - // above answers nothing here, so the switch is what decided it. - withStore([{ integrationId: "opencode-go", value: "sk-fixture-go" }]); - await expect(resolveGoApiKey(composition())).resolves.toBeUndefined(); - await expect( - resolveGoApiKey(composition(), undefined, "auto", false) - ).resolves.toBeUndefined(); - await expect( - resolveGoApiKey(composition(), undefined, "request", false) - ).resolves.toBeUndefined(); - }); - - it("still loses to a credential the deployment declared", async () => { - // The rule the whole feature is arranged around: everything the user said - // explicitly outranks anything inferred from another program's files. - withStore([{ integrationId: "opencode-go", value: "sk-fixture-go" }]); - process.env.OPENCODE_GO_API_KEY = "sk-declared-go"; - await expect( - resolveGoApiKey(composition(), undefined, "auto", true) - ).resolves.toBe("sk-declared-go"); - }); - - it("still loses to a key captured from a live request", async () => { - withStore([{ integrationId: "opencode-go", value: "sk-fixture-go" }]); - recordCapturedApiKey("sk-captured-go", "opencode-go", GO_REQUEST); - await expect( - resolveGoApiKey(composition(), undefined, "auto", true) - ).resolves.toBe("sk-captured-go"); - }); - - it("matches the plane's integration rather than any row", async () => { - withStore([{ integrationId: "opencode", value: "oc_sk_fixture_zen" }]); - // The Go lookup asks for the Go integration, so a Zen-only store answers it - // nothing... - await expect( - resolveGoApiKey(composition(), undefined, "auto", true) - ).resolves.toBeUndefined(); - // ...while the Zen integration resolves that same row. The plane is what - // the store is keyed by, and a reader that took the first row would hand - // the Go endpoint a Zen credential. - expect(resolveOpencodeStoreKey("opencode", true)).toBe("oc_sk_fixture_zen"); - }); - - it("answers undefined for a store that holds nothing", async () => { - // An installation with no credentials, and a machine without one at all, - // are the same answer: not an error, and not a request failure. - withStore([]); - await expect( - resolveGoApiKey(composition(), undefined, "auto", true) - ).resolves.toBeUndefined(); - }); -}); diff --git a/test/harness-context-remotes.test.ts b/test/harness-context-remotes.test.ts deleted file mode 100644 index dc7b1761..00000000 --- a/test/harness-context-remotes.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Test: Cordis Context Variations, Remote Services & Scope Isolation. - * - * Validates: - * - Service access patterns: ctx.get(name) vs property access - * - Scope isolation and child scopes via ctx.inject([services], scope => ...) - * - Typert remotes availability (opencodeGoUsage, modelDirectories) - * - Remote error handling, code mapping, and retry flags - * - Graceful degradation when services or remotes are absent - * - * @module test/harness-context-remotes.test - */ - -import { describe, expect, it } from "vitest"; - -import { HarnessSimulator } from "./mock/testing-kit.ts"; - -describe("Cordis Context & Remote Services", () => { - it("provides service lookup via ctx.get() and handles undeclared services safely", () => { - const harness = new HarnessSimulator({ - services: { - customService: { active: true }, - }, - }); - const { ctx } = harness.createMockContext(); - - expect(ctx.get("customService")).toEqual({ active: true }); - expect(ctx.get("undeclaredService")).toBeUndefined(); - }); - - it("isolates child scopes and injects requested services via ctx.inject()", () => { - const harness = new HarnessSimulator({ - services: { - llm: { model: "default" }, - }, - }); - const { ctx } = harness.createMockContext(); - - let childScope: unknown; - ( - ctx.inject as ( - deps: readonly string[], - cb: (scope: unknown) => unknown - ) => unknown - )(["llm"], (scope) => { - childScope = scope; - }); - - expect(childScope).toBeDefined(); - expect((childScope as { get: (k: string) => unknown }).get("llm")).toEqual({ - model: "default", - }); - }); - - it("handles presence of Typert remotes (opencodeGoUsage and modelDirectories)", async () => { - const harness = new HarnessSimulator({ - remotes: { - opencodeGoUsage: { - read: async () => ({ - rolling: { - percent: 42, - resetsAt: "2026-11-01T00:00:00.000Z", - status: "ok", - }, - }), - }, - modelDirectories: { - directoryFor: async (): Promise => undefined, - }, - }, - }); - const { ctx } = harness.createMockContext(); - - const usageRemote = ctx.remote.opencodeGoUsage as { - read: () => Promise<{ rolling: { percent: number } }>; - }; - const usageData = await usageRemote.read(); - expect(usageData.rolling.percent).toBe(42); - - // Arbitrary namespaces pass through: the harness does not know which remote a - // plugin reads, and this build reads only the meter's. - const directories = ctx.remote.modelDirectories as { - directoryFor: () => Promise; - }; - expect(await directories.directoryFor()).toBeUndefined(); - }); - - it("handles absence of remotes gracefully without crashing", () => { - const harness = new HarnessSimulator({ - remotes: {}, - }); - const { ctx } = harness.createMockContext(); - - expect(ctx.remote.opencodeGoUsage).toBeUndefined(); - expect(ctx.remote.modelDirectories).toBeUndefined(); - }); -}); diff --git a/test/harness-loader-versions.test.ts b/test/harness-loader-versions.test.ts deleted file mode 100644 index b97d52ef..00000000 --- a/test/harness-loader-versions.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Test: DeepSeek Harness Loader Generations & Host Plugin Discovery. - * - * Validates: - * - Legacy loader generation (entries retain module namespace directly) - * - Modern 0.2.0-rc.2 / 0.2.1 loader generation (fiber-only entries; module resolved via ctx.baseUrl) - * - Degraded loader handling (missing loader, empty entries, non-iterable entries) - * - Safe host adapter discovery and isolation across harness versions - * - * @module test/harness-loader-versions.test - */ - -import { describe, expect, it } from "vitest"; - -import { HarnessSimulator } from "./mock/testing-kit.ts"; - -describe("DeepSeek Harness: Loader Versions & Plugin Discovery", () => { - it("discovers host plugin from Legacy loader entry retaining namespace", () => { - const harness = new HarnessSimulator({ - loaderGeneration: "legacy-namespace", - }); - const { ctx, loaderEntries } = harness.createMockContext(); - - expect(loaderEntries.length).toBe(1); - const entry = loaderEntries[0] as { - name: string; - namespace: { - PiAiAdapter: unknown; - apply: unknown; - inject: string[]; - name: string; - }; - }; - expect(entry.name).toBe("@deepseek-ai/dsh-llm-pi-ai"); - expect(entry.namespace.PiAiAdapter).toBeDefined(); - expect(entry.namespace.name).toBe("llm-pi-ai"); - expect(entry.namespace.inject).toContain("llm"); - expect(ctx.get("loader")).toBeDefined(); - }); - - it("handles Modern 0.2.0-rc.2 / 0.2.1 loader carrying fiber without namespace", () => { - const harness = new HarnessSimulator({ - baseUrl: new URL("../", import.meta.url).href, - loaderGeneration: "fiber-only", - }); - const { ctx, loaderEntries } = harness.createMockContext(); - - expect(loaderEntries.length).toBe(1); - const entry = loaderEntries[0] as { - fiber: { inject: { llm: null }; runtime: { callback: () => unknown } }; - namespace?: unknown; - options: { name: string }; - }; - expect(entry.fiber).toBeDefined(); - expect(entry.namespace).toBeUndefined(); - expect(entry.options.name).toBe("@deepseek-ai/dsh-llm-pi-ai"); - expect(ctx.baseUrl).toBeDefined(); - expect(ctx.baseUrl.startsWith("file://")).toBe(true); - }); - - it("handles degraded loader with empty entries and provides scope isolation", () => { - const harness = new HarnessSimulator({ - loaderGeneration: "degraded", - }); - const { ctx, loaderEntries } = harness.createMockContext(); - - expect(loaderEntries.length).toBe(0); - - let capturedScope: unknown; - ( - ctx.inject as ( - deps: readonly string[], - cb: (scope: unknown) => unknown - ) => unknown - )(["llm"], (scope) => { - capturedScope = scope; - }); - expect(capturedScope).toBe(ctx); - }); - - it("survives absent loader and missing baseUrl gracefully", () => { - const contextWithoutLoader: { - baseUrl?: string; - get?: (key: string) => unknown; - } = { - get: () => null, - }; - - expect(contextWithoutLoader.get?.("loader")).toBeNull(); - expect(contextWithoutLoader.baseUrl).toBeUndefined(); - }); -}); diff --git a/test/host-characterization.test.ts b/test/host-characterization.test.ts index 06c1722f..c7afd6cd 100644 --- a/test/host-characterization.test.ts +++ b/test/host-characterization.test.ts @@ -45,6 +45,7 @@ import { characterizedProviders, drainStream, fakeCredentials, + modelIds, PLANES, piAiRestarts, piKnows, @@ -59,6 +60,7 @@ import { todayRowsFor, type TodayRow, UNKNOWN_ID, + until, waitForPlaneOwner, } from "./support/host.ts"; import { @@ -68,10 +70,6 @@ import { reasoningFor, } from "./support/src.ts"; -/** The ids of a listing, in order. */ -const modelIds = (models: readonly { id: string }[]): string[] => - models.map((model) => model.id); - type RowKey = keyof typeof S0_TODAY.rows; let tier: TierB | undefined; @@ -107,23 +105,6 @@ afterEach(async () => { vi.unstubAllGlobals(); }); -/** Poll until `predicate` holds: a committed edit reaches the plane on a microtask. */ -const until = async ( - predicate: () => boolean | Promise, - what: string, - deadlineMs = 5_000 -): Promise => { - const end = Date.now() + deadlineMs; - while (!(await predicate())) { - if (Date.now() > end) { - throw new Error(`never happened: ${what}`); - } - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - } -}; - /** pi-ai's row as the profile file holds it. */ const savedPiRow = ( host: TierB diff --git a/test/host-discovery.test.ts b/test/host-discovery.test.ts index c21fe1a6..35e2a7a6 100644 --- a/test/host-discovery.test.ts +++ b/test/host-discovery.test.ts @@ -52,6 +52,7 @@ import { s0Providers, type TierA, type TierB, + until, waitForPlaneOwner, } from "./support/host.ts"; import { @@ -180,23 +181,6 @@ const viaGateway = async ( return { answer, schema: invocation.result.create() }; }; -/** Poll until `predicate` holds, or fail by name. */ -const until = async ( - predicate: () => boolean | Promise, - what: string, - deadlineMs = 10_000 -): Promise => { - const end = Date.now() + deadlineMs; - while (!(await predicate())) { - if (Date.now() > end) { - throw new Error(`never happened: ${what}`); - } - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - } -}; - /** Boot tier B offline with a fresh wire recorder (stubbed before the boot). */ const boot = async ( providers: Record, diff --git a/test/host-editor-contracts.test.ts b/test/host-editor-contracts.test.ts index 6d76d480..1db08435 100644 --- a/test/host-editor-contracts.test.ts +++ b/test/host-editor-contracts.test.ts @@ -33,6 +33,7 @@ import { PLANES, providerWithApiKey, recordingFetch, + routeIds, s0Providers, type TierB, waitForPlaneOwner, @@ -112,9 +113,6 @@ const describedRefs = (tier: TierB, from: number): string[] => .filter(([method]) => method === "describe") .map(([, ref]) => ref); -const routeIds = (tier: TierB): string[] => - tier.llm.listProviders().map((provider) => provider.id); - let tier: TierB | undefined; afterEach(async () => { @@ -135,7 +133,7 @@ describe( prepare: prependStrip, providers: knownProviders(), }); - expect(routeIds(tier)).toEqual(["deepseek"]); + expect(routeIds(tier.llm)).toEqual(["deepseek"]); await tier.ctx.configEditor.edit( tier.piEntry, editProvider("opencode", { displayName: "Zen (edited)" }) @@ -217,7 +215,7 @@ describe( expect(adaptersUpdated).toBe(0); expect(tier.piEntry.fiber).toBe(before); expect(tier.piFiber.state).toBe(2); - expect(routeIds(tier)).toEqual(["deepseek"]); + expect(routeIds(tier.llm)).toEqual(["deepseek"]); // Control: an edit pi-ai DOES see commits one volatile update, and pi-ai // re-registers in place — the route replace and the directory replace diff --git a/test/host-harness.test.ts b/test/host-harness.test.ts deleted file mode 100644 index a9ffc20a..00000000 --- a/test/host-harness.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * The harness's own seams, each exercised once (`test/support/host.ts`). - * - * P-R1 builds on options no contract or characterization case passes: this - * plugin mounted in tier A (through the host-module seam, since tier A has no - * loader), a profile row for it in tier B, and a custom responder on the - * recording fetch. A builder nobody has run is a builder whose behaviour a - * later phase would assume, so each is run here and what it does is recorded. So are - * tier B's two failure paths: a first row whose config the schema rejects (in - * each apply order), and a key the contributor's shell exports. - * - * @module test/host-harness.test - */ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { - adaptersOf, - type ApplyOrder, - bootTierB, - characterizedProviders, - drainStream, - fakeCredentials, - mountTierA, - PLANES, - piAiRestarts, - recordingFetch, - S0_TODAY, - s0Providers, - type TierA, - type TierB, - waitForPlaneOwner, -} from "./support/host.ts"; - -/** The ids of a listing, in order. */ -const modelIds = (models: readonly { id: string }[]): string[] => - models.map((model) => model.id); - -let tierA: TierA | undefined; -let tierB: TierB | undefined; - -afterEach(async () => { - try { - await tierA?.dispose(); - await tierB?.dispose(); - } finally { - tierA = undefined; - tierB = undefined; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - } -}); - -/** cordis `FiberState`: the two a booted row ends in here. */ -const ACTIVE = 2; -const FAILED = 3; - -/** A two-chunk chat-completions stream that ends with `stop`. */ -const completionStream = (model: string): Response => { - const chunk = (delta: object, finish: string | null) => - `data: ${JSON.stringify({ - choices: [{ delta, finish_reason: finish, index: 0 }], - created: 1, - id: "harness", - model, - object: "chat.completion.chunk", - })}\n\n`; - return new Response( - `${chunk({ content: "hello", role: "assistant" }, null)}${chunk({}, "stop")}data: [DONE]\n\n`, - { headers: { "content-type": "text/event-stream" }, status: 200 } - ); -}; - -describe( - "host harness · its seams (tiers A and B)", - { timeout: 30_000 }, - () => { - it("tier A with the plugin: it reads the host's modules through the seam, claims both planes with one pi-ai restart (pi-ai mounted first), and disposal hands them back and restores fetch", async () => { - vi.spyOn(console, "info").mockImplementation(() => { - // Silenced: the activation line. - }); - const wire = recordingFetch(); - vi.stubGlobal("fetch", wire.fetch); - let restarts: ReturnType | undefined; - tierA = await mountTierA({ - before: (ctx) => { - restarts = piAiRestarts(ctx); - }, - plugin: {}, - providers: { - opencode: { models: [{ id: "deepseek-v4-pro" }] }, - "opencode-go": { models: [{ id: "deepseek-v4-pro" }] }, - }, - }); - expect(tierA.pluginFiber?.state).toBe(2); - expect(globalThis.fetch).not.toBe(wire.fetch); - // Tier A mounts pi-ai first, so it held the planes; the mount restarted - // it once, through the claim's listener, and registered its own adapter. - await waitForPlaneOwner(tierA.llm); - expect(restarts?.count()).toBe(1); - expect( - adaptersOf(tierA.llm).get("opencode")?.adapter.constructor.name - ).toBe("PlaneAdapter"); - expect([...adaptersOf(tierA.llm).keys()].toSorted()).toEqual([...PLANES]); - await tierA.pluginFiber?.dispose(); - await waitForPlaneOwner(tierA.llm, PLANES, "PiAiAdapter"); - expect(globalThis.fetch).toBe(wire.fetch); - }); - - it("tier B with pluginConfig: the profile row reaches the plugin's fiber config", async () => { - vi.stubGlobal("fetch", recordingFetch().fetch); - tierB = await bootTierB({ pluginConfig: { debug: true } }); - const config = tierB.pluginEntry?.fiber?.config as - | { debug?: unknown } - | undefined; - // `debug` defaults to false, so only the row can make it true. - expect(config?.debug).toBe(true); - }); - - it("recordingFetch with a responder: a turn is answered by it and still recorded, so a test can drive a stream to completion", async () => { - const wire = recordingFetch({ - respond: (request) => completionStream(request.model ?? ""), - }); - vi.stubGlobal("fetch", wire.fetch); - tierA = await mountTierA({ - credentials: fakeCredentials({ refs: { OPENCODE_API_KEY: "k" } }), - providers: { - opencode: { - apiKeyEnv: "OPENCODE_API_KEY", - models: [{ id: "deepseek-v4-pro" }], - }, - }, - }); - const drained = await drainStream( - tierA.llm, - "opencode", - "deepseek-v4-pro" - ); - expect(drained.error).toBeUndefined(); - expect(drained.finish).toEqual({ code: undefined, kind: "stop" }); - expect(drained.types).toContain("text-delta"); - expect(wire.requests.map((request) => request.model)).toEqual([ - "deepseek-v4-pro", - ]); - }); - it.each([ - [ - "plugin-first", - "an invalid plugin row", - { pluginConfig: { debug: "not-a-boolean" } }, - ], - [ - "pi-ai-first", - "an invalid pi-ai profile", - { providers: { deepseek: { displayName: 42 } } }, - ], - ] as const)( - "tier B, %s with %s: the first row FAILS without applying, the held row is still released, and the boot finishes promptly", - async (order, _what, rows) => { - // Before the fallback release, the held row waited for an `apply` that - // never came, and the boot hung until the hook timed out. - vi.spyOn(process.stderr, "write").mockImplementation(() => true); - vi.stubGlobal("fetch", recordingFetch().fetch); - tierB = await bootTierB({ order: order as ApplyOrder, ...rows }); - const [failedEntry, heldEntry] = - order === "plugin-first" - ? [tierB.pluginEntry, tierB.piEntry] - : [tierB.piEntry, tierB.pluginEntry]; - expect(failedEntry?.fiber?.state).toBe(FAILED); - // The real error is readable, not a hook timeout. - await expect(failedEntry?.fiber?.await()).rejects.toThrow( - order === "plugin-first" ? /debug/u : /displayName/u - ); - expect(heldEntry?.fiber?.state).toBe(ACTIVE); - }, - 15_000 - ); - - it("tier B with OPENCODE_API_KEY exported: the boot blanks it, so the deployment measured is still the keyless one, and dispose puts it back", async () => { - vi.stubEnv("OPENCODE_API_KEY", "sk-ambient-not-a-key"); - vi.stubEnv("OPENCODE_GO_API_KEY", "sk-ambient-go-not-a-key"); - vi.stubGlobal("fetch", recordingFetch().fetch); - tierB = await bootTierB({ - credentials: fakeCredentials({ - refs: { - OPENCODE_API_KEY: "zen-test", - OPENCODE_GO_API_KEY: "go-test", - }, - }), - providers: characterizedProviders(), - }); - await waitForPlaneOwner(tierB.llm); - expect(process.env.OPENCODE_API_KEY).toBe(""); - expect(process.env.OPENCODE_GO_API_KEY).toBe(""); - // The listing no longer depends on a key (D21): every declared id. - for (const plane of PLANES) { - expect(modelIds(await tierB.llm.listModels(plane)), plane).toEqual( - S0_TODAY.listingBeforeTurn[plane] - ); - } - await tierB.dispose(); - tierB = undefined; - expect(process.env.OPENCODE_API_KEY).toBe("sk-ambient-not-a-key"); - expect(process.env.OPENCODE_GO_API_KEY).toBe("sk-ambient-go-not-a-key"); - }); - - it("tier B leaves an unset key unset", async () => { - // oxlint-disable-next-line unicorn/no-useless-undefined -- `stubEnv` deletes the variable only when handed `undefined`, and restores it after. - vi.stubEnv("OPENCODE_API_KEY", undefined); - tierB = await bootTierB({ plugin: false, providers: s0Providers() }); - expect(process.env.OPENCODE_API_KEY).toBeUndefined(); - await tierB.dispose(); - tierB = undefined; - expect(process.env.OPENCODE_API_KEY).toBeUndefined(); - }); - } -); diff --git a/test/host-planes-config.test.ts b/test/host-planes-config.test.ts new file mode 100644 index 00000000..1538c2a8 --- /dev/null +++ b/test/host-planes-config.test.ts @@ -0,0 +1,228 @@ +/** + * The plugin owning the plane routes: how an edit flows through the claim, + * and which routes are registered (tier B; D13). + * + * @module test/host-planes-config.test + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + appendTo, + bootPlanes, + callConfigOf, + disposeBooted, + editProvider, + planeProfile, + type Providers, + removeProviders, + routeIds, + settle, +} from "./support/host-planes.ts"; +import { + characterizedProviders, + FIBER_STATE, + s0Providers, + SAVE_CANDIDATE, + until, + waitForPlaneOwner, + type TierA, + type TierB, +} from "./support/host.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; + +let tierA: TierA | undefined; +let tierB: TierB | undefined; + +afterEach(async () => { + try { + await tierA?.dispose(); + await tierB?.dispose(); + await disposeBooted(); + } finally { + tierA = undefined; + tierB = undefined; + clearCapturedApiKeys(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + } +}); + +describe("host planes · config flow (tier B)", { timeout: 30_000 }, () => { + it("a plane-only save is served with no pi-ai restart; a displayName edit renames the route; a purge edit is accepted", async () => { + const { restarts, tier } = await bootPlanes(); + await waitForPlaneOwner(tier.llm); + const before = restarts(); + await tier.ctx.configEditor.edit( + tier.piEntry, + appendTo("opencode", SAVE_CANDIDATE) + ); + await until(async () => { + const listed = await tier.llm.listModels("opencode"); + return listed.some((model) => model.id === SAVE_CANDIDATE); + }, "the saved id is listed"); + expect(restarts() - before).toBe(0); + await tier.ctx.configEditor.edit( + tier.piEntry, + editProvider("opencode", { displayName: "Zen X" }) + ); + await until( + () => + tier.llm.listProviders().find((provider) => provider.id === "opencode") + ?.name === "Zen X", + "the rename reaches the registry" + ); + // The purge action's shape: an entry with a model removed. + await tier.ctx.configEditor.edit(tier.piEntry, (current) => { + const entry = (current.providers as Providers)["opencode-go"] as { + models: { id: string }[]; + }; + return editProvider("opencode-go", { + models: entry.models.filter((model) => model.id !== "minimax-m3"), + })(current); + }); + await until(async () => { + const listed = await tier.llm.listModels("opencode-go"); + return !listed.some((model) => model.id === "minimax-m3"); + }, "the purged id leaves the listing"); + }); + + it("a malformed plane candidate is refused in pi-ai's words, and the file is byte-identical", async () => { + const { tier } = await bootPlanes(); + await waitForPlaneOwner(tier.llm); + const before = readFileSync(tier.profile.patchPath); + await expect( + tier.ctx.configEditor.edit( + tier.piEntry, + editProvider("opencode", { models: [{ id: "" }] }) + ) + ).rejects.toThrow(/has a model with an empty id/u); + expect(readFileSync(tier.profile.patchPath)).toEqual(before); + }); + + it("a save a sibling route refuses changes nothing: not the file, not the plane's listing, not its profile", async () => { + const { tier } = await bootPlanes(); + await waitForPlaneOwner(tier.llm); + const before = readFileSync(tier.profile.patchPath); + const listed = await tier.llm.listModels("opencode"); + const profile = planeProfile(tier.llm, "opencode"); + await expect( + tier.ctx.configEditor.edit(tier.piEntry, (current) => + appendTo( + "opencode", + SAVE_CANDIDATE + )(editProvider("deepseek", { api: "nope" })(current)) + ) + ).rejects.toThrow(); + expect(readFileSync(tier.profile.patchPath)).toEqual(before); + expect(await tier.llm.listModels("opencode")).toEqual(listed); + expect(planeProfile(tier.llm, "opencode")).toBe(profile); + }); + + it("a save a home-level patch refuses changes nothing: not the file, not the plane's listing, not its profile", async () => { + const { tier } = await bootPlanes(); + await waitForPlaneOwner(tier.llm); + const before = readFileSync(tier.profile.patchPath); + const listed = await tier.llm.listModels("opencode"); + const profile = planeProfile(tier.llm, "opencode"); + // A home patch pins pi-ai's row to the config it already has; the editor + // refuses a profile edit whose result the home layer overrides. + writeFileSync( + join(tier.home, "cordis.patch.yml"), + `${JSON.stringify([{ config: { providers: characterizedProviders() }, id: "llm-pi-ai" }], null, 2)}\n` + ); + await expect( + tier.ctx.configEditor.edit( + tier.piEntry, + appendTo("opencode", SAVE_CANDIDATE) + ) + ).rejects.toThrow(/overridden by a home patch/u); + expect(readFileSync(tier.profile.patchPath)).toEqual(before); + expect(await tier.llm.listModels("opencode")).toEqual(listed); + expect(planeProfile(tier.llm, "opencode")).toBe(profile); + }); + + it("a stored malformed entry leaves deepseek serving and answers INVALID_CONFIG for that one id only", async () => { + const providers = s0Providers(); + (providers.opencode as { models: { id: string }[] }).models.push({ + id: "gpt-5.4", + }); + const { tier } = await bootPlanes({ providers }); + await waitForPlaneOwner(tier.llm); + expect(await callConfigOf(tier.llm, "opencode", "gpt-5.4")).toBe( + "INVALID_CONFIG" + ); + expect(await callConfigOf(tier.llm, "opencode", "deepseek-v4-pro")).toBe( + "ok" + ); + // The gap id pi's catalog does not describe resolves: the plugin, not + // pi-ai, serves the plane. + expect( + await callConfigOf(tier.llm, "opencode", "gpt-5.3-codex-spark") + ).toBe("ok"); + expect(routeIds(tier.llm)).toContain("deepseek"); + }); + + it("a stored plane entry malformed at route level, which would fail pi-ai's whole fiber, leaves pi-ai active and deepseek serving, and that plane answers INVALID_CONFIG", async () => { + const providers = s0Providers(); + // An invalid header name is a route-level error pi-ai raises for the + // whole providers dict; the listener keeps it away from pi-ai. + providers.opencode = { + ...providers.opencode, + headers: { "bad header": "x" }, + }; + const { tier } = await bootPlanes({ providers }); + await waitForPlaneOwner(tier.llm, ["opencode-go"]); + await settle(); + expect(tier.piFiber.state).toBe(FIBER_STATE.ACTIVE); + expect(await callConfigOf(tier.llm, "deepseek", "deepseek-v4-pro")).toBe( + "ok" + ); + expect(await callConfigOf(tier.llm, "opencode", "deepseek-v4-pro")).toBe( + "INVALID_CONFIG" + ); + }); +}); + +describe("host planes · the route set (tier B)", { timeout: 30_000 }, () => { + it("only the planes the profile declares are registered", async () => { + const providers = s0Providers(); + delete providers["opencode-go"]; + const { tier } = await bootPlanes({ providers }); + await waitForPlaneOwner(tier.llm, ["opencode"]); + expect(routeIds(tier.llm)).toEqual(["deepseek", "opencode"]); + }); + + it("a first plane added at runtime registers; removing both withdraws them through the live handle, and re-adding registers again", async () => { + const { deepseek } = s0Providers(); + const { tier } = await bootPlanes({ + providers: { deepseek: deepseek ?? {} }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + expect(routeIds(tier.llm)).toEqual(["deepseek"]); + const zen = s0Providers().opencode ?? {}; + await tier.ctx.configEditor.edit( + tier.piEntry, + editProvider("opencode", zen) + ); + await waitForPlaneOwner(tier.llm, ["opencode"]); + await tier.ctx.configEditor.edit( + tier.piEntry, + removeProviders("opencode", "opencode-go") + ); + await until( + () => routeIds(tier.llm).length === 1, + "the last plane is withdrawn" + ); + expect(routeIds(tier.llm)).toEqual(["deepseek"]); + await tier.ctx.configEditor.edit( + tier.piEntry, + editProvider("opencode", zen) + ); + await waitForPlaneOwner(tier.llm, ["opencode"]); + }); +}); diff --git a/test/host-planes-credentials.test.ts b/test/host-planes-credentials.test.ts new file mode 100644 index 00000000..8214efe0 --- /dev/null +++ b/test/host-planes-credentials.test.ts @@ -0,0 +1,278 @@ +/** + * The plugin owning the plane routes: where a key comes from (D20), that + * nothing of a sibling route leaks into a plane, and every declared id listed + * on both planes. + * + * @module test/host-planes-credentials.test + */ +import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + disposeBooted, + offline, + planeProfile, + turn, +} from "./support/host-planes.ts"; +import { + adaptersOf, + bootTierB, + CHARACTERIZED_MODELS, + characterizedProviders, + fakeCredentials, + modelIds, + mountTierA, + PLANES, + type TierA, + type TierB, + waitForPlaneOwner, +} from "./support/host.ts"; +import { + createOpencodeStore, + type FixtureStore, +} from "./support/opencode-store-fixture.ts"; +import { clearCapturedApiKeys, recordCapturedApiKey } from "./support/src.ts"; + +let tierA: TierA | undefined; + +let tierB: TierB | undefined; + +afterEach(async () => { + try { + await tierA?.dispose(); + await tierB?.dispose(); + await disposeBooted(); + } finally { + tierA = undefined; + tierB = undefined; + clearCapturedApiKeys(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + } +}); + +describe("host planes · credentials (tier A, D20)", { timeout: 30_000 }, () => { + const mountWith = async ( + credentials: ReturnType, + entry: Record = {} + ): Promise => { + offline(); + tierA = await mountTierA({ + credentials, + plugin: {}, + providers: { + opencode: { models: [{ id: "deepseek-v4-pro" }], ...entry }, + }, + }); + await waitForPlaneOwner(tierA.llm, ["opencode"]); + return tierA; + }; + + it("with no apiKeyEnv, pi's own discovery reads the credentials service's value", async () => { + const tier = await mountWith( + fakeCredentials({ refs: { OPENCODE_API_KEY: "svc-k" } }) + ); + const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(requests[0]?.headers.authorization).toBe("Bearer svc-k".length); + }); + + it("a Sign-in record under llm-pi-ai/opencode authenticates the plane", async () => { + const credentials = fakeCredentials({ + records: { "llm-pi-ai/opencode": { key: "stored-k", kind: "api-key" } }, + }); + const tier = await mountWith(credentials); + const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(requests[0]?.headers.authorization).toBe("Bearer stored-k".length); + expect(credentials.calls).toContainEqual([ + "readRecord", + "llm-pi-ai/opencode", + ]); + }); + + describe("OpenCode's own store, opted into, is the LAST resort", () => { + const stores: FixtureStore[] = []; + + afterEach(() => { + for (const store of stores.splice(0)) { + store.cleanup(); + } + vi.unstubAllEnvs(); + }); + + /** A store holding a Zen key, and the plugin opted into reading it. */ + const mountOptedIn = async ( + credentials: ReturnType + ): Promise => { + const store = createOpencodeStore([ + { integrationId: "opencode", value: "oc_sk_from_the_store" }, + ]); + stores.push(store); + vi.stubEnv("XDG_DATA_HOME", store.root); + offline(); + tierA = await mountTierA({ + credentials, + plugin: { readOpencodeStore: true }, + providers: { opencode: { models: [{ id: "deepseek-v4-pro" }] } }, + }); + await waitForPlaneOwner(tierA.llm, ["opencode"]); + return tierA; + }; + + it("answers when pi's own lookup finds nothing (the known answer)", async () => { + const tier = await mountOptedIn(fakeCredentials()); + const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(requests[0]?.headers.authorization).toBe( + "Bearer oc_sk_from_the_store".length + ); + }); + + it("never pre-empts an OPENCODE_API_KEY the credentials service holds", async () => { + const tier = await mountOptedIn( + fakeCredentials({ refs: { OPENCODE_API_KEY: "svc-k" } }) + ); + const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(requests[0]?.headers.authorization).toBe("Bearer svc-k".length); + }); + + it("never pre-empts a Sign-in record under llm-pi-ai/opencode", async () => { + const tier = await mountOptedIn( + fakeCredentials({ + records: { + "llm-pi-ai/opencode": { key: "stored-k", kind: "api-key" }, + }, + }) + ); + const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(requests[0]?.headers.authorization).toBe("Bearer stored-k".length); + }); + }); + + it("a named ref that resolves nothing still lists the models, and the turn fails MISSING_CREDENTIAL without a request", async () => { + const tier = await mountWith(fakeCredentials(), { + apiKeyEnv: "OPENCODE_API_KEY", + }); + expect(modelIds(await tier.llm.listModels("opencode"))).toEqual([ + "deepseek-v4-pro", + ]); + const { drained, requests } = await turn( + tier.llm, + "opencode", + "deepseek-v4-pro" + ); + expect(drained.finish).toEqual({ + code: "MISSING_CREDENTIAL", + kind: "error", + }); + expect(requests).toEqual([]); + }); + + it("a credentials-service failure propagates rather than reading as a missing key", async () => { + const credentials = fakeCredentials(); + (credentials.service as Record).resolve = () => + Promise.reject(new Error("vault down")); + const tier = await mountWith(credentials, { + apiKeyEnv: "OPENCODE_API_KEY", + }); + await expect(tier.llm.listModels("opencode")).rejects.toThrow( + /vault down/u + ); + const { drained, requests } = await turn( + tier.llm, + "opencode", + "deepseek-v4-pro" + ); + // The turn fails on the service's own error, and nothing is sent: a + // swallowed rejection would read as a missing key or go out keyless. + expect(requests).toEqual([]); + expect(drained.finish?.kind).toBe("error"); + expect(drained.finish?.code).not.toBe("MISSING_CREDENTIAL"); + expect(drained.failureMessage ?? drained.error).toMatch(/vault down/u); + }); + + it("a rotated key takes effect on the next turn", async () => { + const credentials = fakeCredentials({ refs: { OPENCODE_API_KEY: "k1" } }); + const tier = await mountWith(credentials, { + apiKeyEnv: "OPENCODE_API_KEY", + }); + const first = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + await ( + credentials.service.set as unknown as ( + ref: string, + value: string + ) => Promise + )("OPENCODE_API_KEY", "k2-longer"); + const second = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(first.requests[0]?.headers.authorization).toBe("Bearer k1".length); + expect(second.requests[0]?.headers.authorization).toBe( + "Bearer k2-longer".length + ); + }); + + it("a captured key never answers for a named ref that resolves nothing", async () => { + recordCapturedApiKey("opencode", "cap"); + const tier = await mountWith(fakeCredentials(), { + apiKeyEnv: "OPENCODE_API_KEY", + }); + const { drained, requests } = await turn( + tier.llm, + "opencode", + "deepseek-v4-pro" + ); + expect(drained.finish?.code).toBe("MISSING_CREDENTIAL"); + expect(requests).toEqual([]); + }); +}); + +describe("host planes · isolation (tier A)", { timeout: 30_000 }, () => { + it("a sibling's headers, base, api, reasoning and retry never reach a plane", async () => { + offline(); + tierA = await mountTierA({ + credentials: fakeCredentials({ refs: { OPENCODE_API_KEY: "k" } }), + plugin: {}, + providers: { + deepseek: { + api: "openai-responses", + apiKeyEnv: "DEEPSEEK_API_KEY", + baseURL: "http://127.0.0.1:9/v1", + headers: { "x-sib": "1" }, + reasoning: "high", + retryPolicy: { maxRetries: 1, mode: "normal" }, + }, + opencode: { + apiKeyEnv: "OPENCODE_API_KEY", + models: [{ id: "deepseek-v4-pro" }], + }, + }, + }); + await waitForPlaneOwner(tierA.llm, ["opencode"]); + const { requests } = await turn(tierA.llm, "opencode", "deepseek-v4-pro"); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + "https://opencode.ai/inference/openai/v1/chat/completions" + ); + expect(requests[0]?.headers).not.toHaveProperty("x-sib"); + expect(adaptersOf(tierA.llm).get("opencode")?.retryPolicy).toEqual( + resolveRetryPolicy(undefined, "t") + ); + const profile = planeProfile(tierA.llm, "opencode"); + expect(profile.headers).toEqual({}); + expect(profile.reasoning).toBeUndefined(); + }); +}); + +describe( + "host planes · the declared listing (tier B)", + { timeout: 30_000 }, + () => { + it("lists every declared id on both planes, keyless or not", async () => { + offline(); + tierB = await bootTierB({ providers: characterizedProviders() }); + await waitForPlaneOwner(tierB.llm); + for (const plane of PLANES) { + expect(modelIds(await tierB.llm.listModels(plane))).toEqual( + CHARACTERIZED_MODELS[plane] + ); + } + }); + } +); diff --git a/test/host-planes.test.ts b/test/host-planes.test.ts index 9f588ab1..d712e9b4 100644 --- a/test/host-planes.test.ts +++ b/test/host-planes.test.ts @@ -8,66 +8,68 @@ * route leaks into a plane. Every case boots its own host and reads the * registry, the profile file and the wire — never the plugin's own state. * + * This file holds who owns the planes and the claim's lifecycle; its siblings + * hold the config flow and the route set (`host-planes-config`) and the + * credentials and isolation (`host-planes-credentials`). The shared boot and + * readers are `support/host-planes.ts`. + * * @module test/host-planes.test */ import { readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { readProfilePatches, reconcileProfilePatches, } from "@deepseek-ai/dsh-app-boot"; -import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - adaptersOf, + appendTo, + bootPlanes, + disposeBooted, + offline, + routeIds, + settle, + stateOf, + wire, +} from "./support/host-planes.ts"; +import { type ApplyOrder, bootTierB, - CHARACTERIZED_MODELS, characterizedProviders, drainStream, fakeCredentials, - type HostLlm, - logLines, + FIBER_STATE, mountTierA, ownerOf, PLANES, - piAiRestarts, - recordingFetch, - type RecordingFetch, S0_TODAY, s0Providers, SAVE_CANDIDATE, type TierA, type TierB, + until, waitForPlaneOwner, } from "./support/host.ts"; -import { - createOpencodeStore, - type FixtureStore, -} from "./support/opencode-store-fixture.ts"; import { apply, clearCapturedApiKeys, Config, inject, + LEGACY_NAME, + LEGACY_PKG, name, - recordCapturedApiKey, } from "./support/src.ts"; -/** The ids of a listing, in order. */ -const modelIds = (models: readonly { id: string }[]): string[] => - models.map((model) => model.id); - let tierA: TierA | undefined; + let tierB: TierB | undefined; -let wire: RecordingFetch; afterEach(async () => { try { await tierA?.dispose(); await tierB?.dispose(); + await disposeBooted(); } finally { tierA = undefined; tierB = undefined; @@ -77,157 +79,6 @@ afterEach(async () => { } }); -/** cordis `FiberState`. */ -const ACTIVE = 2; -const FAILED = 3; - -/** A fiber's state, read as a plain value. */ -const stateOf = (fiber: { readonly state: unknown } | undefined): unknown => - fiber?.state; - -/** Poll until `predicate` holds, or fail by name. */ -const until = async ( - predicate: () => boolean | Promise, - what: string, - deadlineMs = 10_000 -): Promise => { - const end = Date.now() + deadlineMs; - while (!(await predicate())) { - if (Date.now() > end) { - throw new Error(`never happened: ${what}`); - } - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - } -}; - -/** Stub `fetch` with a fresh recorder (before mounting the plugin). */ -const offline = (respond?: Parameters[0]): void => { - wire = recordingFetch(respond); - vi.stubGlobal("fetch", wire.fetch); -}; - -type Providers = Record>; - -/** An edit of one provider entry, the way a settings surface derives one. */ -const editProvider = - (route: string, patch: Record) => - (current: Record): Record => { - const providers = { ...(current.providers as Providers) }; - providers[route] = { ...providers[route], ...patch }; - return { ...current, providers }; - }; - -/** An edit removing provider entries, the way a settings surface derives one. */ -const removeProviders = - (...routes: string[]) => - (current: Record): Record => ({ - ...current, - providers: Object.fromEntries( - Object.entries(current.providers as Providers).filter( - ([route]) => !routes.includes(route) - ) - ), - }); - -/** A config-editor mutation appending `id` to a plane's models. */ -const appendTo = - (plane: string, id: string) => (current: Record) => { - const entry = (current.providers as Providers)[plane] as - | { models?: { id: string }[] } - | undefined; - return editProvider(plane, { - models: [...(entry?.models ?? []), { id }], - })(current); - }; - -const routeIds = (llm: HostLlm): string[] => - llm - .listProviders() - .map((provider) => provider.id) - .toSorted(); - -/** The plane adapter's resolved profile for a route (its private `config`). */ -const planeProfile = (llm: HostLlm, route: string): Record => { - const adapter = adaptersOf(llm).get(route)?.adapter as unknown as { - config: { profiles: () => ReadonlyMap> }; - }; - const profile = adapter.config.profiles().get(route); - if (profile === undefined) { - throw new Error(`no plane profile for ${route}`); - } - return profile; -}; - -/** One turn's gateway requests, the catalog read excluded. */ -const turn = async (llm: HostLlm, route: string, model: string) => { - const before = wire.requests.length; - const drained = await drainStream(llm, route, model); - return { - drained, - requests: wire.requests - .slice(before) - .filter( - (request) => !request.url.startsWith("https://models.opencode.ai/") - ), - }; -}; - -const callConfigOf = async ( - llm: HostLlm, - route: string, - model: string -): Promise => { - try { - await llm.resolveCallConfig({ model, provider: route } as never); - return "ok"; - } catch (error) { - return String((error as { code?: unknown }).code); - } -}; - -/** Boot tier B with the characterization profile, counting pi-ai's restarts. */ -const bootPlanes = async ( - options: { - readonly order?: ApplyOrder; - readonly providers?: Providers; - } = {} -): Promise<{ - tier: TierB; - restarts: () => number; - logged: () => string[]; -}> => { - offline(); - let restarts: ReturnType | undefined; - let log: ReturnType | undefined; - tierB = await bootTierB({ - credentials: fakeCredentials({ - refs: { OPENCODE_API_KEY: "zen-k", OPENCODE_GO_API_KEY: "go-k" }, - }), - order: options.order ?? "plugin-first", - prepare: (root) => { - restarts = piAiRestarts(root); - log = logLines(root); - }, - providers: options.providers ?? characterizedProviders(), - }); - return { - logged: () => log?.lines() ?? [], - restarts: () => restarts?.count() ?? 0, - tier: tierB, - }; -}; - -/** Let every queued microtask and timer of a settled host run. */ -const settle = async (): Promise => { - for (let index = 0; index < 10; index += 1) { - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - } -}; - describe( "host planes · ownership and restart counts (tier B)", { timeout: 30_000 }, @@ -246,8 +97,8 @@ describe( // A DUPLICATE_ADAPTER that escaped into pi-ai's apply fails pi-ai; one // the claim caught is logged as "still held elsewhere", and any other // refusal as "did not register". None of them happens in either order. - expect(tier.piFiber.state).toBe(ACTIVE); - expect(tier.pluginEntry?.fiber?.state).toBe(ACTIVE); + expect(tier.piFiber.state).toBe(FIBER_STATE.ACTIVE); + expect(tier.pluginEntry?.fiber?.state).toBe(FIBER_STATE.ACTIVE); tier.ctx.logger.info("[host-planes] the log is read"); const lines = logged(); expect(lines.at(-1)).toContain("[host-planes] the log is read"); @@ -258,9 +109,46 @@ describe( ) ) ).toEqual([]); + // A row filed under the current id and package is not told to rename. + expect(lines).not.toContainEqual( + expect.stringContaining("has been renamed") + ); } ); + it.each([ + [ + "plugin-first", + "an invalid plugin row", + { pluginRow: { config: { debug: "not-a-boolean" } } }, + ], + [ + "pi-ai-first", + "an invalid pi-ai profile", + { providers: { deepseek: { displayName: 42 } } }, + ], + ] as const)( + "tier B, %s with %s: the first row FAILS without applying, the held row is still released, and the boot finishes promptly", + async (order, _what, rows) => { + // Before the fallback release, the held row waited for an `apply` that + // never came, and the boot hung until the hook timed out. + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + offline(); + tierB = await bootTierB({ order: order as ApplyOrder, ...rows }); + const [failedEntry, heldEntry] = + order === "plugin-first" + ? [tierB.pluginEntry, tierB.piEntry] + : [tierB.piEntry, tierB.pluginEntry]; + expect(failedEntry?.fiber?.state).toBe(FIBER_STATE.FAILED); + // The real error is readable, not a hook timeout. + await expect(failedEntry?.fiber?.await()).rejects.toThrow( + order === "plugin-first" ? /debug/u : /displayName/u + ); + expect(heldEntry?.fiber?.state).toBe(FIBER_STATE.ACTIVE); + }, + 15_000 + ); + it("pi-ai FAILED for a sibling route while its plane entries are valid: nothing is claimed, the plugin stays active, and no plane is listed", async () => { const providers = characterizedProviders(); // A route-level error on `deepseek` fails pi-ai's whole fiber; the @@ -269,8 +157,8 @@ describe( const { tier } = await bootPlanes({ order: "pi-ai-first", providers }); await until( () => - stateOf(tier.piFiber) === FAILED && - stateOf(tier.pluginEntry?.fiber) === ACTIVE, + stateOf(tier.piFiber) === FIBER_STATE.FAILED && + stateOf(tier.pluginEntry?.fiber) === FIBER_STATE.ACTIVE, "pi-ai fails and the plugin is active" ); // The claim reconciles on every registry change and after its mount; @@ -292,6 +180,30 @@ describe( ); describe("host planes · lifecycle", { timeout: 30_000 }, () => { + // Either half of the old filing is enough: the notice fires on the legacy + // id OR the legacy package, so each is booted on its own (the other half is + // the current one). + it.each([ + ["id", { id: LEGACY_NAME }], + ["package", { name: LEGACY_PKG }], + ] as const)( + "a row still filed under the pre-rename %s is told to rename, and serves the planes all the same (tier B)", + async (_half, pluginRow) => { + const { logged, tier } = await bootPlanes({ pluginRow }); + await waitForPlaneOwner(tier.llm); + // The row really is filed the way the case says. + expect(tier.pluginEntry?.options.id).toBe( + "id" in pluginRow ? pluginRow.id : name + ); + tier.ctx.logger.info("[host-planes] the log is read"); + const lines = logged(); + expect(lines.at(-1)).toContain("[host-planes] the log is read"); + expect(lines).toContainEqual( + expect.stringContaining(`"${LEGACY_PKG}" has been renamed to "${name}"`) + ); + } + ); + it("disabling the plugin's row hands both planes back to pi-ai, and the next Settings save of a plane holding a gap id is refused with the file unchanged (tier B)", async () => { const { tier } = await bootPlanes(); await waitForPlaneOwner(tier.llm); @@ -333,8 +245,16 @@ describe("host planes · lifecycle", { timeout: 30_000 }, () => { offline(); tierA = await mountTierA({ plugin: {}, providers: s0Providers() }); await waitForPlaneOwner(tierA.llm); + expect(globalThis.fetch, "the mounted plugin patches fetch").not.toBe( + wire.fetch + ); await tierA.pluginFiber?.dispose(); await waitForPlaneOwner(tierA.llm, PLANES, "PiAiAdapter"); + // The real composition's teardown — the claim restarting pi-ai while the + // patch unwinds — leaves the wire's own fetch in place. + expect(globalThis.fetch, "the disposed plugin restored fetch").toBe( + wire.fetch + ); await tierA.ctx.plugin( { apply, Config, inject, name } as never, {} as never @@ -440,406 +360,3 @@ describe("host planes · lifecycle", { timeout: 30_000 }, () => { expect(drained.types).toContain("text-delta"); }); }); - -describe("host planes · config flow (tier B)", { timeout: 30_000 }, () => { - it("a plane-only save is served with no pi-ai restart; a displayName edit renames the route; a purge edit is accepted", async () => { - const { restarts, tier } = await bootPlanes(); - await waitForPlaneOwner(tier.llm); - const before = restarts(); - await tier.ctx.configEditor.edit( - tier.piEntry, - appendTo("opencode", SAVE_CANDIDATE) - ); - await until(async () => { - const listed = await tier.llm.listModels("opencode"); - return listed.some((model) => model.id === SAVE_CANDIDATE); - }, "the saved id is listed"); - expect(restarts() - before).toBe(0); - await tier.ctx.configEditor.edit( - tier.piEntry, - editProvider("opencode", { displayName: "Zen X" }) - ); - await until( - () => - tier.llm.listProviders().find((provider) => provider.id === "opencode") - ?.name === "Zen X", - "the rename reaches the registry" - ); - // The purge action's shape: an entry with a model removed. - await tier.ctx.configEditor.edit(tier.piEntry, (current) => { - const entry = (current.providers as Providers)["opencode-go"] as { - models: { id: string }[]; - }; - return editProvider("opencode-go", { - models: entry.models.filter((model) => model.id !== "minimax-m3"), - })(current); - }); - await until(async () => { - const listed = await tier.llm.listModels("opencode-go"); - return !listed.some((model) => model.id === "minimax-m3"); - }, "the purged id leaves the listing"); - }); - - it("a malformed plane candidate is refused in pi-ai's words, and the file is byte-identical", async () => { - const { tier } = await bootPlanes(); - await waitForPlaneOwner(tier.llm); - const before = readFileSync(tier.profile.patchPath); - await expect( - tier.ctx.configEditor.edit( - tier.piEntry, - editProvider("opencode", { models: [{ id: "" }] }) - ) - ).rejects.toThrow(/has a model with an empty id/u); - expect(readFileSync(tier.profile.patchPath)).toEqual(before); - }); - - it("a save a sibling route refuses changes nothing: not the file, not the plane's listing, not its profile", async () => { - const { tier } = await bootPlanes(); - await waitForPlaneOwner(tier.llm); - const before = readFileSync(tier.profile.patchPath); - const listed = await tier.llm.listModels("opencode"); - const profile = planeProfile(tier.llm, "opencode"); - await expect( - tier.ctx.configEditor.edit(tier.piEntry, (current) => - appendTo( - "opencode", - SAVE_CANDIDATE - )(editProvider("deepseek", { api: "nope" })(current)) - ) - ).rejects.toThrow(); - expect(readFileSync(tier.profile.patchPath)).toEqual(before); - expect(await tier.llm.listModels("opencode")).toEqual(listed); - expect(planeProfile(tier.llm, "opencode")).toBe(profile); - }); - - it("a save a home-level patch refuses changes nothing: not the file, not the plane's listing, not its profile", async () => { - const { tier } = await bootPlanes(); - await waitForPlaneOwner(tier.llm); - const before = readFileSync(tier.profile.patchPath); - const listed = await tier.llm.listModels("opencode"); - const profile = planeProfile(tier.llm, "opencode"); - // A home patch pins pi-ai's row to the config it already has; the editor - // refuses a profile edit whose result the home layer overrides. - writeFileSync( - join(tier.home, "cordis.patch.yml"), - `${JSON.stringify([{ config: { providers: characterizedProviders() }, id: "llm-pi-ai" }], null, 2)}\n` - ); - await expect( - tier.ctx.configEditor.edit( - tier.piEntry, - appendTo("opencode", SAVE_CANDIDATE) - ) - ).rejects.toThrow(/overridden by a home patch/u); - expect(readFileSync(tier.profile.patchPath)).toEqual(before); - expect(await tier.llm.listModels("opencode")).toEqual(listed); - expect(planeProfile(tier.llm, "opencode")).toBe(profile); - }); - - it("a stored malformed entry leaves deepseek serving and answers INVALID_CONFIG for that one id only", async () => { - const providers = s0Providers(); - (providers.opencode as { models: { id: string }[] }).models.push({ - id: "gpt-5.4", - }); - const { tier } = await bootPlanes({ providers }); - await waitForPlaneOwner(tier.llm); - expect(await callConfigOf(tier.llm, "opencode", "gpt-5.4")).toBe( - "INVALID_CONFIG" - ); - expect(await callConfigOf(tier.llm, "opencode", "deepseek-v4-pro")).toBe( - "ok" - ); - // The gap id pi's catalog does not describe resolves: the plugin, not - // pi-ai, serves the plane. - expect( - await callConfigOf(tier.llm, "opencode", "gpt-5.3-codex-spark") - ).toBe("ok"); - expect(routeIds(tier.llm)).toContain("deepseek"); - }); - - it("a stored plane entry malformed at route level, which would fail pi-ai's whole fiber, leaves pi-ai active and deepseek serving, and that plane answers INVALID_CONFIG", async () => { - const providers = s0Providers(); - // An invalid header name is a route-level error pi-ai raises for the - // whole providers dict; the listener keeps it away from pi-ai. - providers.opencode = { - ...providers.opencode, - headers: { "bad header": "x" }, - }; - const { tier } = await bootPlanes({ providers }); - await waitForPlaneOwner(tier.llm, ["opencode-go"]); - await settle(); - expect(tier.piFiber.state).toBe(ACTIVE); - expect(await callConfigOf(tier.llm, "deepseek", "deepseek-v4-pro")).toBe( - "ok" - ); - expect(await callConfigOf(tier.llm, "opencode", "deepseek-v4-pro")).toBe( - "INVALID_CONFIG" - ); - }); -}); - -describe("host planes · the route set (tier B)", { timeout: 30_000 }, () => { - it("only the planes the profile declares are registered", async () => { - const providers = s0Providers(); - delete providers["opencode-go"]; - const { tier } = await bootPlanes({ providers }); - await waitForPlaneOwner(tier.llm, ["opencode"]); - expect(routeIds(tier.llm)).toEqual(["deepseek", "opencode"]); - }); - - it("a first plane added at runtime registers; removing both withdraws them through the live handle, and re-adding registers again", async () => { - const { deepseek } = s0Providers(); - const { tier } = await bootPlanes({ - providers: { deepseek: deepseek ?? {} }, - }); - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); - expect(routeIds(tier.llm)).toEqual(["deepseek"]); - const zen = s0Providers().opencode ?? {}; - await tier.ctx.configEditor.edit( - tier.piEntry, - editProvider("opencode", zen) - ); - await waitForPlaneOwner(tier.llm, ["opencode"]); - await tier.ctx.configEditor.edit( - tier.piEntry, - removeProviders("opencode", "opencode-go") - ); - await until( - () => routeIds(tier.llm).length === 1, - "the last plane is withdrawn" - ); - expect(routeIds(tier.llm)).toEqual(["deepseek"]); - await tier.ctx.configEditor.edit( - tier.piEntry, - editProvider("opencode", zen) - ); - await waitForPlaneOwner(tier.llm, ["opencode"]); - }); -}); - -describe("host planes · credentials (tier A, D20)", { timeout: 30_000 }, () => { - const mountWith = async ( - credentials: ReturnType, - entry: Record = {} - ): Promise => { - offline(); - tierA = await mountTierA({ - credentials, - plugin: {}, - providers: { - opencode: { models: [{ id: "deepseek-v4-pro" }], ...entry }, - }, - }); - await waitForPlaneOwner(tierA.llm, ["opencode"]); - return tierA; - }; - - it("with no apiKeyEnv, pi's own discovery reads the credentials service's value", async () => { - const tier = await mountWith( - fakeCredentials({ refs: { OPENCODE_API_KEY: "svc-k" } }) - ); - const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); - expect(requests[0]?.headers.authorization).toBe("Bearer svc-k".length); - }); - - it("a Sign-in record under llm-pi-ai/opencode authenticates the plane", async () => { - const credentials = fakeCredentials({ - records: { "llm-pi-ai/opencode": { key: "stored-k", kind: "api-key" } }, - }); - const tier = await mountWith(credentials); - const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); - expect(requests[0]?.headers.authorization).toBe("Bearer stored-k".length); - expect(credentials.calls).toContainEqual([ - "readRecord", - "llm-pi-ai/opencode", - ]); - }); - - describe("OpenCode's own store, opted into, is the LAST resort", () => { - const stores: FixtureStore[] = []; - - afterEach(() => { - for (const store of stores.splice(0)) { - store.cleanup(); - } - vi.unstubAllEnvs(); - }); - - /** A store holding a Zen key, and the plugin opted into reading it. */ - const mountOptedIn = async ( - credentials: ReturnType - ): Promise => { - const store = createOpencodeStore([ - { integrationId: "opencode", value: "oc_sk_from_the_store" }, - ]); - stores.push(store); - vi.stubEnv("XDG_DATA_HOME", store.root); - offline(); - tierA = await mountTierA({ - credentials, - plugin: { readOpencodeStore: true }, - providers: { opencode: { models: [{ id: "deepseek-v4-pro" }] } }, - }); - await waitForPlaneOwner(tierA.llm, ["opencode"]); - return tierA; - }; - - it("answers when pi's own lookup finds nothing (the known answer)", async () => { - const tier = await mountOptedIn(fakeCredentials()); - const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); - expect(requests[0]?.headers.authorization).toBe( - "Bearer oc_sk_from_the_store".length - ); - }); - - it("never pre-empts an OPENCODE_API_KEY the credentials service holds", async () => { - const tier = await mountOptedIn( - fakeCredentials({ refs: { OPENCODE_API_KEY: "svc-k" } }) - ); - const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); - expect(requests[0]?.headers.authorization).toBe("Bearer svc-k".length); - }); - - it("never pre-empts a Sign-in record under llm-pi-ai/opencode", async () => { - const tier = await mountOptedIn( - fakeCredentials({ - records: { - "llm-pi-ai/opencode": { key: "stored-k", kind: "api-key" }, - }, - }) - ); - const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); - expect(requests[0]?.headers.authorization).toBe("Bearer stored-k".length); - }); - }); - - it("a named ref that resolves nothing still lists the models, and the turn fails MISSING_CREDENTIAL without a request", async () => { - const tier = await mountWith(fakeCredentials(), { - apiKeyEnv: "OPENCODE_API_KEY", - }); - expect(modelIds(await tier.llm.listModels("opencode"))).toEqual([ - "deepseek-v4-pro", - ]); - const { drained, requests } = await turn( - tier.llm, - "opencode", - "deepseek-v4-pro" - ); - expect(drained.finish).toEqual({ - code: "MISSING_CREDENTIAL", - kind: "error", - }); - expect(requests).toEqual([]); - }); - - it("a credentials-service failure propagates rather than reading as a missing key", async () => { - const credentials = fakeCredentials(); - (credentials.service as Record).resolve = () => - Promise.reject(new Error("vault down")); - const tier = await mountWith(credentials, { - apiKeyEnv: "OPENCODE_API_KEY", - }); - await expect(tier.llm.listModels("opencode")).rejects.toThrow( - /vault down/u - ); - const { drained, requests } = await turn( - tier.llm, - "opencode", - "deepseek-v4-pro" - ); - // The turn fails on the service's own error, and nothing is sent: a - // swallowed rejection would read as a missing key or go out keyless. - expect(requests).toEqual([]); - expect(drained.finish?.kind).toBe("error"); - expect(drained.finish?.code).not.toBe("MISSING_CREDENTIAL"); - expect(drained.failureMessage ?? drained.error).toMatch(/vault down/u); - }); - - it("a rotated key takes effect on the next turn", async () => { - const credentials = fakeCredentials({ refs: { OPENCODE_API_KEY: "k1" } }); - const tier = await mountWith(credentials, { - apiKeyEnv: "OPENCODE_API_KEY", - }); - const first = await turn(tier.llm, "opencode", "deepseek-v4-pro"); - await ( - credentials.service.set as unknown as ( - ref: string, - value: string - ) => Promise - )("OPENCODE_API_KEY", "k2-longer"); - const second = await turn(tier.llm, "opencode", "deepseek-v4-pro"); - expect(first.requests[0]?.headers.authorization).toBe("Bearer k1".length); - expect(second.requests[0]?.headers.authorization).toBe( - "Bearer k2-longer".length - ); - }); - - it("a captured key never answers for a named ref that resolves nothing", async () => { - recordCapturedApiKey("opencode", "cap"); - const tier = await mountWith(fakeCredentials(), { - apiKeyEnv: "OPENCODE_API_KEY", - }); - const { drained, requests } = await turn( - tier.llm, - "opencode", - "deepseek-v4-pro" - ); - expect(drained.finish?.code).toBe("MISSING_CREDENTIAL"); - expect(requests).toEqual([]); - }); -}); - -describe("host planes · isolation (tier A)", { timeout: 30_000 }, () => { - it("a sibling's headers, base, api, reasoning and retry never reach a plane", async () => { - offline(); - tierA = await mountTierA({ - credentials: fakeCredentials({ refs: { OPENCODE_API_KEY: "k" } }), - plugin: {}, - providers: { - deepseek: { - api: "openai-responses", - apiKeyEnv: "DEEPSEEK_API_KEY", - baseURL: "http://127.0.0.1:9/v1", - headers: { "x-sib": "1" }, - reasoning: "high", - retryPolicy: { maxRetries: 1, mode: "normal" }, - }, - opencode: { - apiKeyEnv: "OPENCODE_API_KEY", - models: [{ id: "deepseek-v4-pro" }], - }, - }, - }); - await waitForPlaneOwner(tierA.llm, ["opencode"]); - const { requests } = await turn(tierA.llm, "opencode", "deepseek-v4-pro"); - expect(requests).toHaveLength(1); - expect(requests[0]?.url).toBe( - "https://opencode.ai/inference/openai/v1/chat/completions" - ); - expect(requests[0]?.headers).not.toHaveProperty("x-sib"); - expect(adaptersOf(tierA.llm).get("opencode")?.retryPolicy).toEqual( - resolveRetryPolicy(undefined, "t") - ); - const profile = planeProfile(tierA.llm, "opencode"); - expect(profile.headers).toEqual({}); - expect(profile.reasoning).toBeUndefined(); - }); -}); - -describe( - "host planes · the declared listing (tier B)", - { timeout: 30_000 }, - () => { - it("lists every declared id on both planes, keyless or not", async () => { - offline(); - tierB = await bootTierB({ providers: characterizedProviders() }); - await waitForPlaneOwner(tierB.llm); - for (const plane of PLANES) { - expect(modelIds(await tierB.llm.listModels(plane))).toEqual( - CHARACTERIZED_MODELS[plane] - ); - } - }); - } -); diff --git a/test/host-waterfall.test.ts b/test/host-waterfall.test.ts index 8e1f45a6..30905b26 100644 --- a/test/host-waterfall.test.ts +++ b/test/host-waterfall.test.ts @@ -21,6 +21,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { deepFreeze, mountTierA, + routeIds, s0Providers, type TierA, waterfallSpy, @@ -39,13 +40,6 @@ const resolvedKeys = (fiber: Fiber): string[] => const rawOf = (fiber: Fiber): unknown => (fiber as unknown as { _config: unknown })._config; -/** The registered route ids, sorted. */ -const routeIds = (tier: TierA): string[] => - tier.llm - .listProviders() - .map((provider) => provider.id) - .toSorted(); - /** An adapter that registers and never streams: the claim's stand-in. */ class StubAdapter extends LlmAdapter { // oxlint-disable-next-line require-yield -- never streams; the registry is the subject. @@ -81,8 +75,8 @@ afterEach(async () => { describe("host · the internal/config waterfall (tier A)", () => { it("F11 — pi-ai registers exactly the keys of its resolved config (llm-pi-ai/src/index.ts:168-176,299,307)", async () => { tier = await mountTierA({ providers: s0Providers() }); - expect(routeIds(tier)).toEqual(resolvedKeys(tier.piFiber)); - expect(routeIds(tier)).toEqual(["deepseek", "opencode", "opencode-go"]); + expect(routeIds(tier.llm)).toEqual(resolvedKeys(tier.piFiber)); + expect(routeIds(tier.llm)).toEqual(["deepseek", "opencode", "opencode-go"]); }); it("F11 — the RESOLVED keys, not the raw ones: with a listener stripping the planes, the routes follow the resolved config while the raw still holds all three", async () => { @@ -92,8 +86,8 @@ describe("host · the internal/config waterfall (tier A)", () => { }, providers: s0Providers(), }); - expect(routeIds(tier)).toEqual(resolvedKeys(tier.piFiber)); - expect(routeIds(tier)).toEqual(["deepseek"]); + expect(routeIds(tier.llm)).toEqual(resolvedKeys(tier.piFiber)); + expect(routeIds(tier.llm)).toEqual(["deepseek"]); expect( Object.keys( (rawOf(tier.piFiber) as { providers: Record }) @@ -247,7 +241,7 @@ describe("host · the internal/config waterfall (tier A)", () => { Object.keys((rawOf(piFiber) as typeof frozen).providers).toSorted() ).toEqual(["deepseek", "opencode", "opencode-go"]); expect(resolvedKeys(piFiber)).toEqual(["deepseek"]); - expect(routeIds(tier)).toEqual(["deepseek"]); + expect(routeIds(tier.llm)).toEqual(["deepseek"]); }); it("F13 (tier A half) — with the planes stripped, registerAdapter(planes) passes the duplicate check and the directory still lists both under llm-pi-ai (llm/src/index.ts:401-445; llm-pi-ai/src/index.ts:128-150)", async () => { @@ -257,13 +251,17 @@ describe("host · the internal/config waterfall (tier A)", () => { }, providers: s0Providers(), }); - expect(routeIds(tier)).toEqual(["deepseek"]); + expect(routeIds(tier.llm)).toEqual(["deepseek"]); const handle = tier.llm.registerAdapter( ["opencode", "opencode-go"], new StubAdapter() ); try { - expect(routeIds(tier)).toEqual(["deepseek", "opencode", "opencode-go"]); + expect(routeIds(tier.llm)).toEqual([ + "deepseek", + "opencode", + "opencode-go", + ]); const directory = tier.llm.listConfigurableProviders(); for (const id of ["opencode", "opencode-go"]) { expect(directory.find((entry) => entry.provider === id)).toMatchObject({ diff --git a/test/keyed-probes.test.ts b/test/keyed-probes.test.ts index 01ff389f..73114442 100644 --- a/test/keyed-probes.test.ts +++ b/test/keyed-probes.test.ts @@ -667,6 +667,7 @@ describe("keyed probes · the CI wiring", () => { "test/e2e/keyed-probes.e2e.ts", "test/support/keyed-probes.ts", "test/support/keyless-matrix.ts", + "test/support/e2e-env.ts", ".github/workflows/ci.yml", ]) { expect(armed.test(path), path).toBe(true); @@ -720,3 +721,48 @@ describe("keyed probes · the CI wiring", () => { } }); }); + +describe("the e2e-host job · its pull-request scope", () => { + const ci = readFileSync( + new URL("../.github/workflows/ci.yml", import.meta.url), + "utf8" + ); + const job = ci.slice(ci.indexOf("\n e2e-host:\n")); + const scopeStep = + job.split(/\n {6}- /u).find((step) => step.includes("id: scope")) ?? ""; + /** The pattern the scope step greps the changed paths with. */ + const scope = /grep -Eq '([^']+)'/u.exec(scopeStep)?.[1]; + + it("the scope runs the binary e2e on its own inputs, the reader its gate comes through included, and not on unrelated paths", () => { + expect(scope).toBeDefined(); + // The ERE is JavaScript-compatible, so the pattern CI runs is the one + // tested here. + const runs = new RegExp(scope ?? "(?!)", "u"); + for (const path of [ + "src/host/route-claim.ts", + "src/lifecycle.ts", + "src/fetch-patch.ts", + "test/e2e/host/probe-plugin.mjs", + "test/e2e/host-binary.e2e.ts", + "test/support/host.ts", + "test/support/host-versions.ts", + "test/support/e2e-env.ts", + "patches/pi.patch", + ".github/workflows/ci.yml", + "vitest.e2e.config.ts", + "vite.config.ts", + ]) { + expect(runs.test(path), path).toBe(true); + } + for (const path of [ + "README.md", + "package.json", + "src/catalog-data.ts", + "src/utils/plane-model.ts", + "test/support/e2e-env.ts.bak", + "test/e2e-env.test.ts", + ]) { + expect(runs.test(path), path).toBe(false); + } + }); +}); diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 16d6d966..6d744a3c 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -1,108 +1,189 @@ /** - * apply (plugin lifecycle). + * apply (plugin lifecycle), on a real cordis `Context`. * - * Split out of the former monolithic `plugin.test.ts`. + * Every case mounts the plugin the way the Loader does (`mountPlugin`), drives + * the stream hook through cordis's own `ctx.waterfall("llm/stream", …)` — the + * call `LlmRuntime` makes — and tears down through `fiber.dispose()`, so the + * plugin's effects run and are undone as a deployment runs and undoes them. + * Only the degraded-composition diagnostics keep a stand-in (`degradedHost`), + * because a real `Context` registers what it is asked to and always has + * `plugin`: a `plugin` that registers nothing, with and without `get` (the + * three unreachable-service diagnostics), and a composition with no `plugin`, + * or with neither `plugin` nor `provide`. * * @module test/lifecycle.test */ +import { AsyncLocalStorage } from "node:async_hooks"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import type { Context } from "@deepseek-ai/cordis"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - type CordisContext, + seedDivergentCatalog, + ZEN_FREE_PROBE, +} from "./support/catalog-fixture.ts"; +import { + degradedHost, + disposeMounted, + mountPlugin, + until, +} from "./support/composition.ts"; +import { offlineFetch } from "./support/offline.ts"; +import { apply, - clearSessionUsageStore, findModelSpecOn, getSessionUsage, inspectPatch, + isRecord, + OPENCODE_GO_CATALOG, + openCodeSessionIdFor, + patchFetch, + resolveConfig, + STORE_NAMESPACE, } from "./support/src.ts"; import { collectUnknown, createMockStream, - isAsyncIterableLike, - isRecord, replacementFetch, + urlOf, waitForFileContent, } from "./test-helpers.ts"; -afterEach(() => { - vi.unstubAllGlobals(); - delete process.env.OPENCODE_SESSION_ID; +afterEach(async () => { + // Disposed first: the patch restores the fetch it wrapped. The leak verdict + // is read BEFORE the stubs are undone, because unstubbing puts the offline + // fetch back over whatever a case left wrapped around its stub — read after, + // a case that stubs `fetch` could leak its patch and still pass. The cleanup + // runs whatever the verdict, and only then is it asserted: a case that + // leaked its patch fails here, by name, and the cases after it start clean + // instead of inheriting the leak and being blamed for it. + let leaked = false; + let lingering: unknown; + try { + await disposeMounted(); + leaked = globalThis.fetch?.name === PATCHED_FETCH; + } finally { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + delete process.env.OPENCODE_SESSION_ID; + if (globalThis.fetch !== offlineFetch) { + lingering = globalThis.fetch; + globalThis.fetch = offlineFetch; + } + } + expect(leaked, "a mounted plugin left its fetch patch").toBe(false); + expect(lingering, "a case left a fetch over the offline one").toBeUndefined(); }); /** - * A stand-in whose `inject` hands itself over as the child scope, the way - * cordis runs an `inject` callback once the service exists: discovery lives in - * the `llm` scope, because the plugin's root injects nothing. + * The name `patchFetch` gives the function it installs, read off the module + * rather than written out, so renaming it in `src/fetch-patch.ts` cannot + * quietly turn the leak check above into a no-op. */ -const withScope = (ctx: T): T => - Object.assign(ctx, { - inject: (_deps: string[], callback: (scope: unknown) => void) => { - callback(ctx); - }, - }); +const PATCHED_FETCH = patchFetch( + offlineFetch, + new AsyncLocalStorage(), + resolveConfig() +).name; + +/** + * The session id a turn with none of its own carries: the derived default of + * the reserved `"default"` conversation, with no pinned id in the environment + * (the afterEach clears it). + */ +const DEFAULT_FALLBACK = openCodeSessionIdFor("default"); + +/** One turn through cordis's own waterfall, as `LlmRuntime` sends it. */ +const stream = ( + ctx: Context, + options: unknown, + inner: () => unknown +): unknown => + ( + ctx as unknown as { + waterfall: (event: string, ...args: unknown[]) => unknown; + } + ).waterfall("llm/stream", options, inner); + +/** Drain one turn's stream. */ +const drain = (result: unknown): Promise => + collectUnknown(result as AsyncIterable); + +/** + * A downstream that yields the turn store it ran under. The plugin's store is + * an `AsyncLocalStorage` of its own, so the store is read where the fetch + * patch reads it: through the patched `fetch`, which the turn's request goes + * out on. The answer echoes the headers the patch put on it. + * + * @yields {Record} the headers the patched request carried. + */ +const echoTurn = async function* echoTurn() { + const response = await globalThis.fetch( + "https://opencode.ai/zen/v1/chat/completions", + { method: "POST" } + ); + yield (await response.json()) as Record; +}; + +/** Stub `fetch` with one that echoes the request's headers back. */ +const echoHeaders = (): void => { + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => + Response.json(Object.fromEntries(new Headers(init?.headers))) + ) + ); +}; + +/** A Host session service answering for the ids it holds. */ +const sessionsHolding = (records: Record) => ({ + get: (id: string): unknown => records[id], +}); describe("apply (plugin lifecycle)", () => { - it("installs the Settings discovery wrapper in the llm scope by default, and none with enrichModels off", () => { + it("installs the Settings discovery wrapper in the llm scope by default, and none with enrichModels off", async () => { + // Three activations, two on one root: their fetch patches nest, so they + // wrap a stub the case owns (see the usage-service case below). + vi.stubGlobal("fetch", replacementFetch); const original = async () => []; - const llm = { discoverModels: original }; - const ctx = withScope({ - effect: () => {}, - llm, - on: () => {}, - }) as unknown as CordisContext; - - void apply(ctx); - const installed = Reflect.getOwnPropertyDescriptor( - llm, - "discoverModels" - )?.value; - expect(installed).not.toBe(original); - expect(inspectPatch(installed, "discoverModels")?.[0]).toMatchObject({ + const llm = { discoverModels: original, listProviders: () => [] }; + await mountPlugin({ llm }); + const wrapped = (): unknown => + Reflect.getOwnPropertyDescriptor(llm, "discoverModels")?.value; + await until(() => wrapped() !== original, "the llm scope wraps discovery"); + expect(inspectPatch(wrapped(), "discoverModels")?.[0]).toMatchObject({ id: "dsh-opencode-patch/model-discovery", live: true, }); - const offLlm = { discoverModels: original }; - const off = withScope({ - effect: () => {}, + // The off composition's llm scope runs too, and wraps nothing: a sibling + // mounted after it on the same service settles first, so the off scope has + // had every chance to install by then. + const offLlm = { discoverModels: original, listProviders: () => [] }; + const off = await mountPlugin({ + config: { enrichModels: false }, llm: offLlm, - on: () => {}, - }) as unknown as CordisContext; - void apply(off, { enrichModels: false }); + }); + await mountPlugin({ ctx: off.ctx }); + await until( + () => + Reflect.getOwnPropertyDescriptor(offLlm, "discoverModels")?.value !== + original, + "the sibling activation wraps discovery" + ); expect( - Reflect.getOwnPropertyDescriptor(offLlm, "discoverModels")?.value - ).toBe(original); - }); - - it("registers the usage service only while the meter is on", () => { - // The meter toggle is ONE switch with two halves in two processes: the Host - // registers the remote face here, and the client's meter injector mounts - // only while that face exists (`settings-page.tsx`). Off means the service - // is never registered, so there is no trigger, no panel and no price row to - // render — absence, not an empty shell. - const mounted: string[] = []; - const ctx = { - effect: () => {}, - llm: {}, - on: () => {}, - plugin: (component: unknown) => { - mounted.push((component as { name?: string })?.name ?? ""); - }, - } as unknown as CordisContext; - - void apply(ctx, { usageEnabled: false }); - expect(mounted).not.toContain("GoUsageService"); - - void apply(ctx, { usageEnabled: true }); - expect(mounted).toContain("GoUsageService"); + inspectPatch( + Reflect.getOwnPropertyDescriptor(offLlm, "discoverModels")?.value, + "discoverModels" + ) + ).toHaveLength(1); }); - it("mounts the usage service once per root, so a reload cannot lose it", () => { + it("mounts the usage service once per root, so a reload cannot lose it", async () => { // Measured on a live host: the plugin is applied more than once (a live // reload does it on every patch). The second activation's registration was // refused as a duplicate because the first was still alive, and when that @@ -111,27 +192,74 @@ describe("apply (plugin lifecycle)", () => { // "the usage meter is not rendering: no usage service: // remote.opencodeGoUsage.read is missing" // - // Reproduced on real cordis: apply → present, apply again → present, - // dispose the first then re-apply → MISSING. On the root, once per process, - // the service survives that ordering. - const mounted: string[] = []; - const ctx = { - effect: () => {}, - llm: {}, - on: () => {}, - plugin: (component: unknown) => { - mounted.push((component as { name?: string })?.name ?? ""); - }, - } as unknown as CordisContext; - // The root IS this context: no `root` property means the composition's own. - ctx.root = ctx; + // The reproduction, on real cordis: apply, apply again on the same root, + // dispose the first. On the root, once per process, the service survives. + vi.stubGlobal("fetch", replacementFetch); + const { ctx, fiber: first } = await mountPlugin({ + config: { usageEnabled: true }, + }); + const { fiber: second } = await mountPlugin({ + config: { usageEnabled: true }, + ctx, + }); + await first.dispose(); + const usage = ctx.get("opencodeGoUsage" as never) as + | { read?: unknown } + | undefined; + expect(usage, "the reload lost the usage service").toBeDefined(); + expect(typeof usage?.read).toBe("function"); + await second.dispose(); + + // KNOWN_DEFECT P6: disposing the first activation while the second's patch + // is on top skips the first's restore (it restores only while current), + // and the second then restores to the first's patch — which outlives both. + // P6 flips this assertion; until then the case owns the stub underneath + // and removes it, so the leftover cannot reach the next case. + expect(globalThis.fetch?.name, "KNOWN_DEFECT P6").toBe(PATCHED_FETCH); + vi.unstubAllGlobals(); + }); + + it("the usage service reads the Go usage endpoint of the composition", async () => { + // The service's base URL is a closure over the composition's config, read + // per call: with no Go row and no `usageBaseURL` it is the plane's + // default, and with a `usageBaseURL` it is that base. Each composition + // mounts on a root of its own, because the service mounts once per root. + vi.stubEnv("OPENCODE_GO_API_KEY", "sk-go-usage-test"); + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + urls.push(urlOf(input)); + return Response.json({}); + }) + ); + const readOn = async (config: Record): Promise => { + const { ctx } = await mountPlugin({ config }); + const usage = ctx.get("opencodeGoUsage" as never) as unknown as { + read: () => Promise; + }; + // The stub answers no usage windows, so the read itself fails; what is + // asserted is where it went. + await expect(usage.read()).rejects.toThrow(/usage response/u); + }; + + await readOn({ usageEnabled: true }); + expect( + urls.some((url) => url.endsWith("/zen/go/v1/usage")), + urls.join(", ") + ).toBe(true); - void apply(ctx, { usageEnabled: true }); - void apply(ctx, { usageEnabled: true }); - expect(mounted.filter((name) => name === "GoUsageService")).toHaveLength(1); + urls.length = 0; + await readOn({ + usageBaseURL: "https://relay.example/zen/go/v1", + usageEnabled: true, + }); + expect(urls, urls.join(", ")).toContain( + "https://relay.example/zen/go/v1/usage" + ); }); - it("reports WHY the usage service is unreachable, in each of the three ways", () => { + it("reports WHY the usage service is unreachable, in each of the three ways", async () => { // A browser console carried: // // the usage meter is not rendering: no usage service: @@ -143,29 +271,41 @@ describe("apply (plugin lifecycle)", () => { // happens with nothing on the client to show it: it never registered, it // registered under another key, or it registered as a VALUE (`ctx.provide`) // rather than a Service. The three are indistinguishable from outside, so the - // host now names which one it is. + // host now names which one it is. A real `Context` registers the service it + // is asked to, so the two failures need a composition that does not. const messages: string[] = []; - const hostWith = (get: () => unknown): unknown => ({ - effect: () => {}, - get, - llm: {}, - logger: { - info: (...args: unknown[]) => messages.push(args.map(String).join(" ")), - warn: (...args: unknown[]) => messages.push(args.map(String).join(" ")), - }, - on: () => {}, - plugin: () => { - /* the real path; not needed for these three cases */ - }, - }); + const logger = { + info: (...args: unknown[]) => messages.push(args.map(String).join(" ")), + warn: (...args: unknown[]) => messages.push(args.map(String).join(" ")), + }; + const applyOn = async (get?: () => unknown): Promise => { + const host = degradedHost({ + ...(get === undefined ? {} : { get }), + logger, + plugin: () => { + /* the real path; registers nothing here */ + }, + }); + try { + await apply(host.ctx, { usageEnabled: true }); + } finally { + host.dispose(); + } + }; // 1. Never registered: `ctx.get` answers nothing for the key. - void apply( - hostWith(() => { - /* an unregistered name */ - }) as never, - { usageEnabled: true } - ); + await applyOn(() => { + /* an unregistered name */ + }); + expect( + messages.some( + (m) => m.includes("did not register") && m.includes("ctx.get") + ) + ).toBe(true); + + // 1b. A composition with no `get` at all reads the same way: nothing answered. + messages.length = 0; + await applyOn(); expect( messages.some( (m) => m.includes("did not register") && m.includes("ctx.get") @@ -175,9 +315,7 @@ describe("apply (plugin lifecycle)", () => { // 2. Registered, but with no `typertRemote` binding — a provided value, which // the Gateway's service scan cannot see. messages.length = 0; - void apply(hostWith(() => ({ read: async () => ({}) })) as never, { - usageEnabled: true, - }); + await applyOn(() => ({ read: async () => ({}) })); expect( messages.some( (m) => @@ -194,7 +332,7 @@ describe("apply (plugin lifecycle)", () => { serviceKey: "opencodeGoUsage", }, }; - void apply(hostWith(() => bound) as never, { usageEnabled: true }); + await applyOn(() => bound); expect( messages.some( (m) => m.includes("reachable") && m.includes("opencodeGoUsage") @@ -202,216 +340,169 @@ describe("apply (plugin lifecycle)", () => { ).toBe(true); }); - it("survives a composition with neither mount path, and reports it", async () => { - // The fallback's whole job is to not give up: `ctx.provide` reaches the same - // lookup the client uses, so a composition with one but not the other still - // publishes both services. A real cordis context is what the service - // constructors need, so this asserts the reachable half - the path is taken, - // and a context with neither mount point is a report rather than a throw. + it("survives a composition with neither mount path, and reports it (KNOWN_DEFECT P5: the provide fallback never publishes)", async () => { + // With no `ctx.plugin`, each installer takes the `ctx.provide` fallback — + // and today that fallback cannot publish anything: it constructs the + // service first, and a service's constructor registers itself through + // `ctx.reflect`, which only a real cordis context has (and a real context + // always has `plugin`). So on this stand-in each service reports, by its + // own name, that it did not mount, `provide` is never called, and `apply` + // carries on without throwing. P5 (`installRemoteService`) either drops the + // provide arm or makes it publish; the empty list below is what it flips. const warned: string[] = []; - const logger = { warn: (msg: string) => warned.push(msg) }; - const withProvide = { - effect: () => {}, - llm: {}, + const logger = { + warn: (...args: unknown[]) => warned.push(args.map(String).join(" ")), + }; + const services = ["opencodeGoUsage", "opencodeModelPurge", STORE_NAMESPACE]; + const provided: string[] = []; + const withProvide = degradedHost({ logger, - on: () => {}, - provide: () => {}, - } as unknown as CordisContext; - await expect( - apply(withProvide, { usageEnabled: true }) - ).resolves.toBeUndefined(); + provide: (name) => { + provided.push(name); + }, + }); + try { + await expect( + apply(withProvide.ctx, { usageEnabled: true }) + ).resolves.toBeUndefined(); + } finally { + withProvide.dispose(); + } + for (const service of services) { + expect(warned, service).toContainEqual( + expect.stringContaining(`${service} did not mount`) + ); + } + expect(warned.join(" ")).not.toContain("neither ctx.plugin"); + expect(provided, "KNOWN_DEFECT P5").toEqual([]); // Neither `plugin` nor `provide`: the installer says so instead of throwing, // which is what keeps one service's absence from taking the rest of `apply` // down with it. - await expect( - apply( - { - effect: () => {}, - llm: {}, - logger, - on: () => {}, - } as unknown as CordisContext, - { usageEnabled: true } - ) - ).resolves.toBeUndefined(); - expect(warned.join(" ")).toContain("neither ctx.plugin nor ctx.provide"); + warned.length = 0; + const neither = degradedHost({ logger }); + try { + await expect( + apply(neither.ctx, { usageEnabled: true }) + ).resolves.toBeUndefined(); + } finally { + neither.dispose(); + } + for (const service of services) { + expect(warned, service).toContainEqual( + expect.stringContaining( + `${service} did not mount: this composition has neither ctx.plugin nor ctx.provide` + ) + ); + } }); - it("survives a host whose llm service has no discoverModels", () => { + it("survives a host whose llm service has no discoverModels", async () => { // Discovery is optional: an older host must still get the header patch - // and the stream hook rather than failing to mount. The llm scope runs - // (`withScope`), so the discovery installer reads this service. - let streamHandler: unknown; - const llm = {}; - const ctx = withScope({ - effect: () => {}, - llm, - on: (_event: string, handler: unknown) => { - streamHandler = handler; - }, - }) as unknown as CordisContext; - - expect(() => apply(ctx)).not.toThrow(); - expect(typeof streamHandler).toBe("function"); + // and the stream hook rather than failing to mount. The llm scope runs, + // so the discovery installer reads this service. + echoHeaders(); + const llm = { listProviders: () => [] }; + const { ctx } = await mountPlugin({ llm }); + // The stream hook attached: a turn carries its derived session id. + const [headers] = await drain( + stream( + ctx, + { model: "m", provider: "opencode", sessionId: "older-host" }, + echoTurn + ) + ); + expect((headers as Record)["x-opencode-session"]).toBe( + openCodeSessionIdFor("older-host") + ); // Nothing was installed on a service with no method to wrap. - expect(Reflect.ownKeys(llm)).toEqual([]); + expect(Reflect.ownKeys(llm)).toEqual(["listProviders"]); }); - it("patches globalThis.fetch and restores it on disposer call", () => { - const originalFetch = globalThis.fetch; - let disposer: unknown; - - const ctx: CordisContext = { - effect: (fn: () => unknown) => { - disposer = fn(); - }, - on: () => {}, - }; - - void apply(ctx); - expect(globalThis.fetch).not.toBe(originalFetch); - - if (typeof disposer === "function") { - disposer(); - } - expect(globalThis.fetch).toBe(originalFetch); + it("patches globalThis.fetch and restores it when the fiber disposes", async () => { + const before = globalThis.fetch; + const { fiber } = await mountPlugin(); + expect(globalThis.fetch).not.toBe(before); + await fiber.dispose(); + expect(globalThis.fetch).toBe(before); }); - it("disposer does not clobber a replacement fetch", () => { - const originalFetch = globalThis.fetch; - let disposer: unknown; - const ctx: CordisContext = { - effect: (fn: () => unknown) => { - disposer = fn(); - }, - on: () => {}, - }; - void apply(ctx); + it("disposer does not clobber a replacement fetch", async () => { + // The case owns the fetch the patch wraps, so removing its stub restores + // the real one whatever the replacement left behind. + vi.stubGlobal("fetch", vi.fn(replacementFetch)); + const { fiber } = await mountPlugin(); globalThis.fetch = replacementFetch; - if (typeof disposer === "function") { - disposer(); - } + await fiber.dispose(); expect(globalThis.fetch).toBe(replacementFetch); - globalThis.fetch = originalFetch; }); - it("attaches to llm/stream and derives session ID", async () => { - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - - const ctx: CordisContext = { - effect: () => {}, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - - void apply(ctx); - expect(typeof streamHandler).toBe("function"); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } - const result: unknown = streamHandler( - { - model: "beam-free", - provider: "opencode", - sessionId: "dsh-session-test-888", - }, - () => createMockStream("stream-chunk-1") + it("attaches to llm/stream through cordis's own waterfall and carries the derived session id into the turn", async () => { + echoHeaders(); + const { ctx } = await mountPlugin(); + const [headers] = await drain( + stream( + ctx, + { + model: "beam-free", + provider: "opencode", + sessionId: "dsh-session-test-888", + }, + echoTurn + ) + ); + expect((headers as Record)["x-opencode-session"]).toBe( + openCodeSessionIdFor("dsh-session-test-888") ); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } - expect(await collectUnknown(result)).toEqual(["stream-chunk-1"]); }); - it("survives context proxies where direct sessions access throws without inject", async () => { - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - - // Simulate Cordis proxy trap that throws when `ctx.sessions` is read directly - const proxyTarget: Record = { - effect: () => {}, - get: (serviceName: string) => - serviceName === "sessions" - ? { - get: (id: string) => ({ - header: { - cwd: "/workspace/project", - parentSession: "ses_parent", - }, - id, - }), - } - : null, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - - const ctx = new Proxy(proxyTarget, { - get(target, prop, receiver) { - if (prop === "sessions") { - throw new Error('cannot get property "sessions" without inject'); - } - return Reflect.get(target, prop, receiver); - }, - has(target, prop) { - if (prop === "sessions") { - return true; - } - return Reflect.has(target, prop); - }, - }) as unknown as CordisContext; - - void apply(ctx); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } - - const result: unknown = streamHandler( - { - model: "beam-free", - provider: "opencode", - sessionId: "dsh-session-proxy-check", - }, - () => createMockStream("stream-ok") + it("reads a sessions service it does not inject, through ctx.get", async () => { + // cordis's own trap: the plugin injects nothing, so `scope.sessions` on its + // scope throws `cannot get property "sessions" without inject`. `ctx.get` + // reads the store without consulting `inject`, which is what an optional + // service needs, so the turn still carries the session's project and parent. + echoHeaders(); + const { ctx } = await mountPlugin(); + ctx.provide( + "sessions" as never, + sessionsHolding({ + "dsh-session-proxy-check": { + header: { cwd: "/workspace/project", parentSession: "lead" }, + }, + }) as never ); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } - expect(await collectUnknown(result)).toEqual(["stream-ok"]); + const [headers] = await drain( + stream( + ctx, + { + model: "beam-free", + provider: "opencode", + sessionId: "dsh-session-proxy-check", + }, + echoTurn + ) + ); + expect(headers).toMatchObject({ + "x-opencode-parent-session-id": openCodeSessionIdFor("lead"), + "x-opencode-project": "project", + "x-opencode-session": openCodeSessionIdFor("dsh-session-proxy-check"), + }); }); it("records dollars from a usage chunk without altering the stream", async () => { - clearSessionUsageStore(); - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - const ctx: CordisContext = { - effect: () => {}, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - void apply(ctx); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); + const { ctx } = await mountPlugin(); + // A paid, priced Go model, so the recorded cost is non-zero. Picked off + // the shim by the shape the recorder prices by (not flagged free, a + // non-zero input rate), so a refresh that reprices or retires one id + // cannot fail it. + const priced = OPENCODE_GO_CATALOG.find( + (row) => row.is_free !== true && (row.cost?.input ?? 0) > 0 + ); + if (priced?.cost === undefined) { + throw new Error("the Go shim carries no paid, priced row"); } - - // A priced Go model, so the recorded cost is non-zero. + const rate = priced.cost.input; + const model = priced.id; const usageChunk = { type: "usage", usage: { @@ -425,52 +516,37 @@ describe("apply (plugin lifecycle)", () => { yield usageChunk; yield "finish"; }; - const result: unknown = streamHandler( + const result = stream( + ctx, { - model: "deepseek-v4.1-flash", + model, provider: "opencode-go", sessionId: "usage-session", }, () => down() ); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } // Every chunk survives in order; the observer only watches. - expect(await collectUnknown(result)).toEqual([ - "text-delta", - usageChunk, - "finish", - ]); + expect(await drain(result)).toEqual(["text-delta", usageChunk, "finish"]); const recorded = getSessionUsage("usage-session"); expect(recorded?.turns).toBe(1); expect(recorded?.inputTokens).toBe(1_000_000); - expect(recorded?.activeModel).toBe("deepseek-v4.1-flash"); - // Priced from the catalog's Go rate rather than a hardcoded number. - expect(recorded?.costUsd).toBeGreaterThan(0); + expect(recorded?.activeModel).toBe(model); + // Priced from the catalog's Go rate rather than a hardcoded number: one + // million input tokens and nothing else cost exactly the per-million rate. + expect(recorded?.costUsd).toBeCloseTo(rate, 6); }); it("prices a turn at the rate of the plane its route belongs to", async () => { // The planes price some shared ids differently. A Go-first lookup billed // every Zen turn on those at the Go rate, so each plane is checked here - // against its own catalog row rather than against a hardcoded number. - clearSessionUsageStore(); - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - const ctx: CordisContext = { - effect: () => {}, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - void apply(ctx); - const model = "deepseek-v4-pro"; + // against its own catalog row rather than against a hardcoded number. The + // id is the seeded probe that is paid on Go and free on Zen by + // construction, so no vendor refresh can align the two prices. + const { ctx } = await mountPlugin(); + await seedDivergentCatalog(); + const model = ZEN_FREE_PROBE; const go = findModelSpecOn("go", model)?.cost?.input; const zen = findModelSpecOn("zen", model)?.cost?.input; // The case only means something while the planes disagree. @@ -489,14 +565,7 @@ describe("apply (plugin lifecycle)", () => { }, }; }; - const result: unknown = streamHandler?.( - { model, provider, sessionId }, - () => down() - ); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } - await collectUnknown(result); + await drain(stream(ctx, { model, provider, sessionId }, () => down())); return getSessionUsage(sessionId)?.costUsd; }; @@ -504,82 +573,65 @@ describe("apply (plugin lifecycle)", () => { expect(await runTurn("opencode", "plane-zen")).toBeCloseTo(zen ?? 0, 6); }); - it("ignores non-opencode providers and invalid session IDs", () => { - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - const ctx: CordisContext = { - effect: () => {}, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - void apply(ctx); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); + it("passes non-opencode turns through untouched, and derives a fallback id for an opencode turn without one", async () => { + echoHeaders(); + const { ctx } = await mountPlugin(); + // The downstream is a stream, which an engaged hook always wraps in a new + // one: identity is what says the hook stayed out of the turn. + for (const options of [ + { provider: "deepseek", sessionId: "abc" }, + null, + "nope", + ]) { + const down = createMockStream("x"); + expect( + stream(ctx, options, () => down), + JSON.stringify(options) + ).toBe(down); } - const handler = streamHandler; - const passthrough = (options: unknown) => - handler(options, () => "next-value"); - expect(passthrough({ provider: "deepseek", sessionId: "abc" })).toBe( - "next-value" - ); - expect(passthrough({ provider: "opencode" })).toBe("next-value"); - expect(passthrough({ provider: "opencode", sessionId: "" })).toBe( - "next-value" - ); - expect(passthrough(null)).toBe("next-value"); - expect(passthrough("nope")).toBe("next-value"); + // An opencode turn with no usable session id is not ignored: the hook + // engages and the request carries the derived default. + for (const options of [ + { provider: "opencode" }, + { provider: "opencode", sessionId: "" }, + ]) { + const down = echoTurn(); + const result = stream(ctx, options, () => down); + expect(result).not.toBe(down); + const [headers] = await drain(result); + expect((headers as Record)["x-opencode-session"]).toBe( + DEFAULT_FALLBACK + ); + } }); - it("warns and skips when globalThis.fetch is unavailable", () => { - const warnings: string[] = []; + it("warns and skips when globalThis.fetch is unavailable", async () => { vi.stubGlobal("fetch", null); - const ctx: CordisContext = { - effect: () => {}, - logger: { - warn: (msg: string) => { - warnings.push(msg); - }, - }, - on: () => { - throw new Error("on must not be called without fetch"); - }, - }; - void apply(ctx); + const { ctx, lines } = await mountPlugin(); expect( - warnings.some((w) => w.includes("globalThis.fetch is unavailable")) + lines().some((line) => line.includes("globalThis.fetch is unavailable")) ).toBe(true); + // No stream hook either: an engaged hook always wraps a downstream stream + // in a new one, so the turn's own stream coming back is the proof. + const down = createMockStream("x"); + expect( + stream( + ctx, + { model: "m", provider: "opencode", sessionId: "no-fetch" }, + () => down + ) + ).toBe(down); + expect(globalThis.fetch).toBeNull(); }); it("records debug entries to debugFile when configured", async () => { const tmpDir = await mkdtemp(path.join(tmpdir(), "dsh-opencode-test-")); try { const debugFile = path.join(tmpDir, "stream-debug.jsonl"); - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - - const ctx: CordisContext = { - effect: () => {}, - logger: { info: () => {} }, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - - void apply(ctx, { debug: true, debugFile }); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } - const result: unknown = streamHandler( + const { ctx } = await mountPlugin({ config: { debug: true, debugFile } }); + const result = stream( + ctx, { model: "beam-free", provider: "opencode", @@ -587,10 +639,7 @@ describe("apply (plugin lifecycle)", () => { }, () => createMockStream("done") ); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } - expect(await collectUnknown(result)).toEqual(["done"]); + expect(await drain(result)).toEqual(["done"]); const content = await waitForFileContent(debugFile); const [firstLine] = content.trim().split("\n"); @@ -605,10 +654,7 @@ describe("apply (plugin lifecycle)", () => { expect(parsed.model).toBe("beam-free"); expect(parsed.provider).toBe("opencode"); expect(parsed.session).toBe("session-debug-999"); - expect(typeof parsed.value).toBe("string"); - if (typeof parsed.value === "string") { - expect(parsed.value).toMatch(/^ses_/); - } + expect(parsed.value).toBe(openCodeSessionIdFor("session-debug-999")); } finally { await rm(tmpDir, { force: true, recursive: true }); } @@ -628,51 +674,35 @@ describe("apply (plugin lifecycle)", () => { const tmpDir = await mkdtemp(path.join(tmpdir(), "dsh-opencode-req-")); try { const debugFile = path.join(tmpDir, "requests.jsonl"); - const originalFetch = globalThis.fetch; - const answering = vi.fn(async () => Response.json({ ok: true })); - globalThis.fetch = answering as unknown as typeof fetch; - let disposer: unknown; - const ctx: CordisContext = { - effect: (fn: () => unknown) => { - disposer = fn(); - }, - logger: { info: () => {} }, - on: () => {}, - }; - try { - void apply(ctx, { debug: true, debugFile }); - const url = "https://opencode.ai/zen/v1/chat/completions"; - const response = await globalThis.fetch(url, { - body: JSON.stringify({ messages: [] }), - headers: {}, - method: "POST", - }); - expect(response.status).toBe(200); - - const content = await waitForFileContent(debugFile, 1, 12_000); - const entries = content - .trim() - .split("\n") - .map((line) => JSON.parse(line)) - .filter(isRecord); - const requestEntries = entries.filter( - (entry) => entry.claimed === true - ); - expect(requestEntries).toHaveLength(1); - const [requestEntry] = requestEntries; - if (!isRecord(requestEntry)) { - throw new Error("no request entry in the debug file"); - } - expect(requestEntry.url).toBe(url); - expect(requestEntry.plane).toBe("opencode"); - expect(requestEntry.status).toBe(200); - expect(requestEntry.sessionFormat).toBe(true); - } finally { - if (typeof disposer === "function") { - disposer(); - } - globalThis.fetch = originalFetch; + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ ok: true })) + ); + await mountPlugin({ config: { debug: true, debugFile } }); + const url = "https://opencode.ai/zen/v1/chat/completions"; + const response = await globalThis.fetch(url, { + body: JSON.stringify({ messages: [] }), + headers: {}, + method: "POST", + }); + expect(response.status).toBe(200); + + const content = await waitForFileContent(debugFile, 1, 12_000); + const entries = content + .trim() + .split("\n") + .map((line) => JSON.parse(line) as unknown) + .filter(isRecord); + const requestEntries = entries.filter((entry) => entry.claimed === true); + expect(requestEntries).toHaveLength(1); + const [requestEntry] = requestEntries; + if (!isRecord(requestEntry)) { + throw new Error("no request entry in the debug file"); } + expect(requestEntry.url).toBe(url); + expect(requestEntry.plane).toBe("opencode"); + expect(requestEntry.status).toBe(200); + expect(requestEntry.sessionFormat).toBe(true); } finally { await rm(tmpDir, { force: true, recursive: true }); } @@ -682,33 +712,14 @@ describe("apply (plugin lifecycle)", () => { const tmpDir = await mkdtemp(path.join(tmpdir(), "dsh-opencode-aff-")); try { const debugFile = path.join(tmpDir, "affinity.jsonl"); - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - const ctx: CordisContext = { - effect: () => {}, - logger: { info: () => {} }, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - void apply(ctx, { debug: true, debugFile }); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } - const handler = streamHandler; + const { ctx } = await mountPlugin({ config: { debug: true, debugFile } }); const driveTurn = async (label: string) => { - const result: unknown = handler( + const result = stream( + ctx, { model: "m", provider: "opencode", sessionId: "same-dsh-session" }, () => createMockStream(label) ); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } - await expect(collectUnknown(result)).resolves.toEqual([label]); + await expect(drain(result)).resolves.toEqual([label]); }; await driveTurn("turn-0"); await driveTurn("turn-1"); @@ -728,79 +739,67 @@ describe("apply (plugin lifecycle)", () => { }); it("captures turn state and wraps streams for auto-review calls where sessionId is omitted", async () => { - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - const ctx: CordisContext = { - effect: () => {}, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - - void apply(ctx); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } + echoHeaders(); + const { ctx } = await mountPlugin(); const autoReviewOptions = { model: "deepseek-v4.1-flash", provider: "opencode-go", }; - const result: unknown = streamHandler(autoReviewOptions, () => - createMockStream("decision-allow") + const down = echoTurn(); + const result = stream(ctx, autoReviewOptions, () => down); + expect(result, "the hook wraps the auto-review turn").not.toBe(down); + const [headers] = await drain(result); + // The turn carries the derived default, so the auto-review request keeps + // affinity with the conversation's fallback rather than going without. + expect((headers as Record)["x-opencode-session"]).toBe( + DEFAULT_FALLBACK ); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } - expect(await collectUnknown(result)).toEqual(["decision-allow"]); }); it("resolves parent session from options or ctx.sessions for subagents", async () => { - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - const ctx: CordisContext = { - effect: () => {}, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - get: (name: string): unknown => - name === "sessions" - ? { - get: (id: string) => - id === "child-subagent-session" - ? { - header: { - id: "child-subagent-session", - parentSession: "root-lead-session", - }, - } - : undefined, - } - : undefined, - }; + echoHeaders(); + const { ctx } = await mountPlugin(); + ctx.provide( + "sessions" as never, + sessionsHolding({ + "child-subagent-session": { + header: { + id: "child-subagent-session", + parentSession: "root-lead-session", + }, + }, + }) as never + ); + const [fromSessions] = await drain( + stream( + ctx, + { + model: "deepseek-v4.1-flash", + provider: "opencode-go", + sessionId: "child-subagent-session", + }, + echoTurn + ) + ); + expect( + (fromSessions as Record)["x-opencode-parent-session-id"] + ).toBe(openCodeSessionIdFor("root-lead-session")); - void apply(ctx); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } - const result: unknown = streamHandler( - { - model: "deepseek-v4.1-flash", - provider: "opencode-go", - sessionId: "child-subagent-session", - }, - () => createMockStream("subagent-chunk") + // An explicit parent on the options outranks the registry's. + const [fromOptions] = await drain( + stream( + ctx, + { + model: "deepseek-v4.1-flash", + parentSessionId: "explicit-parent", + provider: "opencode-go", + sessionId: "child-subagent-session", + }, + echoTurn + ) ); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } - expect(await collectUnknown(result)).toEqual(["subagent-chunk"]); + expect( + (fromOptions as Record)["x-opencode-parent-session-id"] + ).toBe(openCodeSessionIdFor("explicit-parent")); }); }); diff --git a/test/manifest.test.ts b/test/manifest.test.ts index 73c590d0..174d2e5c 100644 --- a/test/manifest.test.ts +++ b/test/manifest.test.ts @@ -16,10 +16,10 @@ import { Config, CONFIG_ONLY_FIELDS, DEFAULT_PROVIDERS, + isRecord, name as PLUGIN_NAME, NS, } from "./support/src.ts"; -import { isRecord } from "./test-helpers.ts"; afterEach(() => { vi.unstubAllGlobals(); diff --git a/test/mock/gateway-contract.mock.ts b/test/mock/gateway-contract.mock.ts deleted file mode 100644 index 38b26111..00000000 --- a/test/mock/gateway-contract.mock.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * The mock gateway's own contract, exercised over a socket. - * - * These cases are the teeth behind the other suites: a request shape they call - * compatible must be one this server *accepts*, and these prove it refuses the - * shapes it should — a model on an endpoint that cannot speak its format, a - * missing session header, a placeholder credential, the double-`/v1` path - * class. Without them a mock that answered 200 to everything would make the - * request-shape suite pass. - * - * Raw fetches, no plugin: the point here is the server, not the patch. - * - * @module test/mock/gateway-contract.mock - */ - -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -import { startMockGateway, type MockGateway } from "./gateway-server.ts"; -import { catalogFor, firstSpecFor, MOCK_SESSION, ZEN_KEY } from "./helpers.ts"; -import type { GatewayRequest } from "./opencode-gateway.ts"; - -let gateway: MockGateway; - -beforeAll(async () => { - gateway = await startMockGateway(); -}); - -afterAll(async () => { - await gateway.close(); -}); - -beforeEach(() => { - gateway.requests.length = 0; -}); - -const post = ( - path: string, - body: unknown, - headers: Record = {} -): Promise => - fetch(`${gateway.origin}${path}`, { - body: JSON.stringify(body), - headers: { - authorization: `Bearer ${ZEN_KEY}`, - "content-type": "application/json", - "x-opencode-session-id": MOCK_SESSION, - ...headers, - }, - method: "POST", - }); - -const recorded = (): GatewayRequest => { - const last = gateway.requests.at(-1); - if (last === undefined) { - throw new Error("the mock gateway saw no request"); - } - return last; -}; - -const responsesBody = (model: string): Record => ({ - input: [], - model, - stream: true, -}); - -describe("the mock gateway", () => { - it("answers a well-formed responses request with the SSE shape", async () => { - const response = await post("/zen/v1/responses", responsesBody("gpt-5.4")); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("text/event-stream"); - const text = await response.text(); - expect(text).toContain("response.created"); - expect(text).toContain("response.output_text.delta"); - expect(text).toContain("response.completed"); - expect(text).toContain("Hello from "); - expect(text).toContain("the mock gateway."); - const request = recorded(); - expect(request.plane).toBe("opencode"); - expect(request.kind).toBe("responses"); - expect(request.protocol).toBe("openai-responses"); - expect(request.model).toBe("gpt-5.4"); - expect(request.reject).toBeNull(); - expect(request.status).toBe(200); - }); - - it("refuses a model on an endpoint that cannot speak its format", async () => { - const response = await post( - "/zen/v1/chat/completions", - responsesBody("gpt-5.4") - ); - expect(response.status).toBe(400); - const payload = (await response.json()) as { - error: { message: string; type: string }; - }; - expect(payload.error.message).toContain("ModelProtocolUnsupported"); - expect(recorded().reject).toBe("protocol"); - }); - - it("refuses a model the plane does not serve", async () => { - const response = await post( - "/zen/v1/responses", - responsesBody("mock-unknown-model-9000") - ); - expect(response.status).toBe(404); - const payload = (await response.json()) as { error: { type: string } }; - expect(payload.error.type).toBe("model_not_found"); - }); - - it("refuses a request with no session header", async () => { - const response = await fetch(`${gateway.origin}/zen/v1/responses`, { - body: JSON.stringify(responsesBody("gpt-5.4")), - headers: { - authorization: `Bearer ${ZEN_KEY}`, - "content-type": "application/json", - }, - method: "POST", - }); - expect(response.status).toBe(400); - const payload = (await response.json()) as { error: { message: string } }; - expect(payload.error.message).toBe("MissingSessionID"); - expect(recorded().reject).toBe("session"); - }); - - it("refuses the adapter's placeholder credential", async () => { - const response = await post("/zen/v1/responses", responsesBody("gpt-5.4"), { - authorization: "Bearer unused", - }); - expect(response.status).toBe(401); - expect(recorded().reject).toBe("credential"); - }); - - it("refuses the wrong auth header for the protocol", async () => { - // A PAID model on purpose: a free one is admitted with no credential at all, - // so it could not show that this endpoint reads `x-api-key` rather than - // `Authorization`. The rule being tested is what a credential-requiring model - // requires. - const spec = catalogFor("opencode").find( - (entry) => - entry.provider_npm === "@ai-sdk/anthropic" && !entry.id.includes("free") - ); - if (spec === undefined) { - throw new Error("the zen catalog serves no paid anthropic model"); - } - // A valid Bearer is present, and that is exactly the point: the Messages - // endpoint reads x-api-key, so a request carrying only authorization is - // unauthenticated there. - const response = await post("/zen/v1/messages?beta=true", { - max_tokens: 64, - messages: [{ content: "hi", role: "user" }], - model: spec.id, - stream: true, - }); - expect(response.status).toBe(401); - expect(recorded().reject).toBe("credential"); - }); - - it("refuses the double-/v1 path class as an unknown endpoint", async () => { - const response = await post("/zen/v1/v1/messages", { - max_tokens: 64, - messages: [{ content: "hi", role: "user" }], - model: "gpt-5.4", - stream: true, - }); - expect(response.status).toBe(404); - expect(recorded().reject).toBe("unknown_endpoint"); - }); - - it("refuses a family it does not serve", async () => { - const response = await post("/v1/responses", responsesBody("gpt-5.4")); - expect(response.status).toBe(404); - expect(recorded().reject).toBe("no_route"); - }); - - it("answers a Google streamGenerateContent request with its SSE shape", async () => { - const spec = firstSpecFor("opencode", "@ai-sdk/google"); - if (spec === undefined) { - throw new Error("the zen catalog serves no Google model"); - } - const response = await fetch( - `${gateway.origin}/zen/v1/models/${spec.id}:streamGenerateContent?alt=sse`, - { - body: JSON.stringify({ - contents: [{ parts: [{ text: "hi" }], role: "user" }], - }), - headers: { - "content-type": "application/json", - "x-goog-api-key": ZEN_KEY, - "x-opencode-session-id": MOCK_SESSION, - }, - method: "POST", - } - ); - expect(response.status).toBe(200); - const text = await response.text(); - expect(text).toContain("finishReason"); - expect(text).toContain("Hello from "); - expect(text).toContain("the mock gateway."); - const request = recorded(); - expect(request.kind).toBe("google"); - expect(request.model).toBe(spec.id); - expect(request.query).toBe("?alt=sse"); - }); - - it("answers the models listing with the vendor's envelope", async () => { - const response = await fetch(`${gateway.origin}/zen/v1/models`, { - headers: { - authorization: `Bearer ${ZEN_KEY}`, - "x-opencode-session-id": MOCK_SESSION, - }, - }); - expect(response.status).toBe(200); - const payload = (await response.json()) as { - data: { id: string }[]; - object: string; - }; - expect(payload.object).toBe("list"); - const [first] = catalogFor("opencode"); - if (first === undefined) { - throw new Error("the zen catalog is empty"); - } - const ids = payload.data.map((row) => row.id); - expect(ids).toContain(first.id); - expect(ids).toContain("mock-unknown-model"); - }); -}); diff --git a/test/mock/helpers.ts b/test/mock/helpers.ts index d731238c..3b33f926 100644 --- a/test/mock/helpers.ts +++ b/test/mock/helpers.ts @@ -31,10 +31,13 @@ import { OPENCODE_GO_CATALOG, OPENCODE_PLANE_API, OPENCODE_ZEN_CATALOG, + openCodeSessionIdFor, + protocolForSdk, type ResolvedPluginConfig, patchFetch, } from "../support/src.ts"; -import type { GatewayKind } from "./opencode-gateway.ts"; +import type { MockGateway } from "./gateway-server.ts"; +import type { GatewayKind, GatewayRequest } from "./opencode-gateway.ts"; export { MOCK_TEXT } from "./opencode-gateway.ts"; @@ -51,9 +54,10 @@ export const GO_KEY = "sk-mock-go"; * The 12 hex digits are `~((ms << 12) + counter)` for a descending id, so a real * one always lands in that band; `0xFFE` is what `counter = 1` produces. A * syntactically valid id outside the band is refused with `403 FreeTierError` — - * measured — which is exactly what this fixture used to be. + * measured — which is exactly what a hand-written fixture here once was. Derived + * the plugin's own way, so it is in the band by construction. */ -export const MOCK_SESSION = "ses_eb1e9e706ffeCDEFGHIJKLMNOP"; +export const MOCK_SESSION = openCodeSessionIdFor("mock-gateway"); /** The key a plane's requests should carry, per the stubs above. */ export const keyFor = (plane: TestPlane): string => @@ -204,6 +208,41 @@ export const firstSpecFor = ( : spec.provider_npm === sdk ); +/** + * The first catalog row one plane serves on `protocol` in `tier`, so no case + * names a vendor id a catalog refresh can retire. Throws when the plane has + * none: a case that cannot find its fixture fails by name, never vacuously. + * + * @param plane - the gateway plane. + * @param protocol - the wire protocol the row's SDK speaks. + * @param tier - whether the row is a free one. + * @returns the row. + */ +export const fixtureModel = ( + plane: TestPlane, + protocol: string, + tier: "free" | "paid" +): CatalogSpec => { + const spec = catalogFor(plane).find( + (row) => + protocolForSdk(row.provider_npm) === protocol && + (row.is_free === true) === (tier === "free") + ); + if (spec === undefined) { + throw new Error(`the ${plane} catalog serves no ${tier} ${protocol} model`); + } + return spec; +}; + +/** The last request the mock gateway saw; throws when it saw none. */ +export const lastRecorded = (gateway: MockGateway): GatewayRequest => { + const last = gateway.requests.at(-1); + if (last === undefined) { + throw new Error("the mock gateway saw no request"); + } + return last; +}; + /** The turn state every request is made under, unless a case overrides it. */ export const sessionTurn = ( overrides: Partial = {} diff --git a/test/mock/opencode-gateway.ts b/test/mock/opencode-gateway.ts index bcb1fe09..3b2e5daf 100644 --- a/test/mock/opencode-gateway.ts +++ b/test/mock/opencode-gateway.ts @@ -2,7 +2,7 @@ * The OpenCode gateway, mocked — the vendor's endpoints as a Vite plugin. * * Divided cleanly by wire protocols (responses, completions, messages, google, - * catalog, console-auth, usage-quota) under `test/mock/protocols/`. + * catalog, usage-quota) under `test/mock/protocols/`. * * @module dsh-opencode-patch/mock-gateway */ @@ -37,133 +37,11 @@ import { export { MOCK_TEXT }; export type { MockUsageOptions }; -export interface MockAuthOptions { - readonly accessToken?: string; - readonly deviceCode?: string; - readonly expiresInSeconds?: number; - readonly intervalSeconds?: number; - readonly orgs?: readonly { readonly id: string; readonly name: string }[]; - readonly pollOutcome?: - | "denied" - | "expired" - | "failed" - | "granted" - | "pending" - | "slow_down"; - readonly refreshToken?: string; - readonly userCode?: string; - readonly userEmail?: string; - readonly userId?: string; -} - -const deviceCodeResponse = (options?: MockAuthOptions): ShapeReply => ({ - body: JSON.stringify({ - device_code: options?.deviceCode ?? "dev_mock_code_12345", - expires_in: options?.expiresInSeconds ?? 600, - interval: options?.intervalSeconds ?? 1, - user_code: options?.userCode ?? "WDJB-MJHT", - verification_uri: "/console/device", - }), - contentType: JSON_CONTENT_TYPE, - status: 200, -}); - -const deviceTokenResponse = (options?: MockAuthOptions): ShapeReply => { - const outcome = options?.pollOutcome ?? "granted"; - if (outcome === "pending") { - return { - body: JSON.stringify({ - error: { - message: "Authorization pending", - type: "authorization_pending", - }, - }), - contentType: JSON_CONTENT_TYPE, - status: 400, - }; - } - if (outcome === "slow_down") { - return { - body: JSON.stringify({ - error: { message: "Slow down", type: "slow_down" }, - interval: 10, - }), - contentType: JSON_CONTENT_TYPE, - status: 400, - }; - } - if (outcome === "denied") { - return { - body: JSON.stringify({ - error: { - message: "The user denied authorization", - type: "access_denied", - }, - }), - contentType: JSON_CONTENT_TYPE, - status: 400, - }; - } - if (outcome === "expired") { - return { - body: JSON.stringify({ - error: { message: "The code has expired", type: "expired_token" }, - }), - contentType: JSON_CONTENT_TYPE, - status: 400, - }; - } - if (outcome === "failed") { - return { - body: JSON.stringify({ - error: { message: "Internal error", type: "server_error" }, - }), - contentType: JSON_CONTENT_TYPE, - status: 500, - }; - } - return { - body: JSON.stringify({ - access_token: options?.accessToken ?? "mock-access-token-12345", - account: options?.userId ?? "acc_mock_user_123", - expires_in: options?.expiresInSeconds ?? 3600, - org: options?.orgs?.[0]?.id ?? "wrk_mock_org_456", - refresh_token: options?.refreshToken ?? "mock-refresh-token-67890", - }), - contentType: JSON_CONTENT_TYPE, - status: 200, - }; -}; - -const userResponse = (options?: MockAuthOptions): ShapeReply => ({ - body: JSON.stringify({ - email: options?.userEmail ?? "developer@example.com", - id: options?.userId ?? "acc_mock_user_123", - }), - contentType: JSON_CONTENT_TYPE, - status: 200, -}); - -const orgsResponse = (options?: MockAuthOptions): ShapeReply => ({ - body: JSON.stringify( - options?.orgs ?? [ - { id: "wrk_mock_org_456", name: "Default Org" }, - { id: "wrk_mock_org_789", name: "Secondary Org" }, - ] - ), - contentType: JSON_CONTENT_TYPE, - status: 200, -}); - /** The plane a request's base URL belongs to. */ export type GatewayPlane = "opencode" | "opencode-go"; /** The endpoint family a path names, by the last segment it carries. */ export type GatewayKind = - | "api-orgs" - | "api-user" - | "auth-device-code" - | "auth-device-token" | "completions" | "google" | "messages" @@ -194,7 +72,6 @@ export type GatewayBehavior = ( ) => MockReply | undefined; export interface OpenCodeGatewayOptions { - auth?: MockAuthOptions; behavior?: GatewayBehavior; onRequest?: (request: GatewayRequest) => void; usage?: MockUsageOptions; @@ -261,10 +138,6 @@ const ENDPOINTS: readonly { ]; const WIRE_HEADER: Readonly> = { - "api-orgs": "authorization", - "api-user": "authorization", - "auth-device-code": "", - "auth-device-token": "", completions: "authorization", google: "x-goog-api-key", messages: "x-api-key", @@ -280,34 +153,6 @@ const CONSOLE_ROUTES: readonly { plane: GatewayPlane; protocol: string | null; }[] = [ - { - kind: "auth-device-code", - method: "POST", - pattern: /^\/(?:console\/)?auth\/device\/code$/u, - plane: "opencode", - protocol: null, - }, - { - kind: "auth-device-token", - method: "POST", - pattern: /^\/(?:console\/)?auth\/device\/token$/u, - plane: "opencode", - protocol: null, - }, - { - kind: "api-user", - method: "GET", - pattern: /^\/(?:console\/)?api\/user$/u, - plane: "opencode", - protocol: null, - }, - { - kind: "api-orgs", - method: "GET", - pattern: /^\/(?:console\/)?api\/orgs$/u, - plane: "opencode", - protocol: null, - }, { kind: "usage", method: "GET", @@ -574,12 +419,7 @@ export const handleGatewayRequest = ( record.protocol = endpoint.protocol; const expectedMethod = - endpoint.kind === "models" || - endpoint.kind === "api-user" || - endpoint.kind === "api-orgs" || - endpoint.kind === "usage" - ? "GET" - : "POST"; + endpoint.kind === "models" || endpoint.kind === "usage" ? "GET" : "POST"; if (record.method !== expectedMethod) { return refuse( 405, @@ -598,11 +438,7 @@ export const handleGatewayRequest = ( endpoint.kind === "responses" || endpoint.kind === "messages" || endpoint.kind === "google"; - if ( - wireHeader.length > 0 && - !perModelCredential && - !isCredential(headers[wireHeader]) - ) { + if (!perModelCredential && !isCredential(headers[wireHeader])) { return refuse(401, "Invalid API key", "invalid_api_key", "credential"); } @@ -643,38 +479,6 @@ export const handleGatewayRequest = ( return accept(usageReply ?? usageResponse(options.usage)); } - if (endpoint.kind === "auth-device-code") { - const codeReply = options.behavior?.(record); - return accept(codeReply ?? deviceCodeResponse(options.auth)); - } - - if (endpoint.kind === "auth-device-token") { - const tokenReply = options.behavior?.(record); - const generated = deviceTokenResponse(options.auth); - if (generated.status >= 400 && tokenReply === undefined) { - const parsed = JSON.parse(generated.body) as { - error: { message: string; type: string }; - }; - return refuse( - generated.status, - parsed.error.message, - parsed.error.type, - options.auth?.pollOutcome ?? "token" - ); - } - return accept(tokenReply ?? generated); - } - - if (endpoint.kind === "api-user") { - const userReply = options.behavior?.(record); - return accept(userReply ?? userResponse(options.auth)); - } - - if (endpoint.kind === "api-orgs") { - const orgsReply = options.behavior?.(record); - return accept(orgsReply ?? orgsResponse(options.auth)); - } - const { ok, parsed } = parseBody(exchange.bodyText); if (!ok) { return refuse( @@ -714,19 +518,17 @@ export const handleGatewayRequest = ( // entry — measured: a keyless free-tier request answers 200), and requires a // real one everywhere else. A placeholder that is neither absent nor `public` // is still refused, on any model. - if (wireHeader.length > 0) { - const presented = headers[wireHeader]; - const bare = presented - ?.replace(/^Bearer\s+/iu, "") - .trim() - .toLowerCase(); - const admitted = - isCredential(presented) || - (model.includes("free") && - (bare === undefined || bare.length === 0 || bare === "public")); - if (!admitted) { - return refuse(401, "Invalid API key", "invalid_api_key", "credential"); - } + const presented = headers[wireHeader]; + const bare = presented + ?.replace(/^Bearer\s+/iu, "") + .trim() + .toLowerCase(); + const admitted = + isCredential(presented) || + (model.includes("free") && + (bare === undefined || bare.length === 0 || bare === "public")); + if (!admitted) { + return refuse(401, "Invalid API key", "invalid_api_key", "credential"); } // **Each protocol has its own contract, and the mock enforces it.** The auth diff --git a/test/e2e/patched-fetch-headers.e2e.ts b/test/mock/patched-fetch-headers.mock.ts similarity index 76% rename from test/e2e/patched-fetch-headers.e2e.ts rename to test/mock/patched-fetch-headers.mock.ts index d1916351..4ad67e4c 100644 --- a/test/e2e/patched-fetch-headers.e2e.ts +++ b/test/mock/patched-fetch-headers.mock.ts @@ -6,8 +6,10 @@ * request, the real `patchFetch`. Everywhere else in the suite the injected * headers are asserted against a captured `fetch`; here they cross a socket. * - * Opt-in (`OPENCODE_E2E=1`): it binds a port, so it stays out of the - * deterministic unit run. + * It binds a loopback port and nothing else, so it runs in the mock suite on + * every push rather than in the unit run. + * + * @module test/mock/patched-fetch-headers.mock */ import assert from "node:assert/strict"; @@ -24,13 +26,12 @@ import { PARENT_SESSION_HEADER, SESSION_AFFINITY_HEADER, SESSION_HEADER, + newOpenCodeMessageId, openCodeSessionIdFor, patchFetch, resolveConfig, } from "../support/src.ts"; -const ENABLED = process.env.OPENCODE_E2E === "1"; - let server: Server; let origin: string; /** Headers of every request the listener received, in order. */ @@ -63,22 +64,27 @@ const headerOf = (index: number, name: string): string | undefined => { return Array.isArray(value) ? value[0] : value; }; -describe.skipIf(!ENABLED)("patched fetch over a real socket", () => { +describe("patched fetch over a real socket", () => { const als = new AsyncLocalStorage(); const config = resolveConfig({}); const patched = patchFetch(fetch, als, config); + /** The turn's own ids, derived rather than written, so each is in band. */ + const TURN_VALUE = openCodeSessionIdFor("e2e-turn"); + const PARENT_VALUE = openCodeSessionIdFor("e2e-parent"); + const REQUEST_VALUE = newOpenCodeMessageId(); + const turn = (overrides: Partial = {}): ActiveTurnState => ({ model: "deepseek-v4.1-flash", provider: "opencode", - requestValue: "msg_e2e0000000abCDEFGHIJKLMNOP", - value: "ses_e2e00000000", + requestValue: REQUEST_VALUE, + value: TURN_VALUE, ...overrides, }); it("injects the full affinity header set for a claimed provider", async () => { const state = turn({ - parentValue: "ses_parent00000", + parentValue: PARENT_VALUE, project: "dsh-opencode", }); const response = await als.run(state, () => patched(`${origin}/models`)); @@ -96,16 +102,14 @@ describe.skipIf(!ENABLED)("patched fetch over a real socket", () => { expect(headerOf(0, "x-session-id")).toBeUndefined(); // Parent lineage, in both spellings. - expect(headerOf(0, PARENT_SESSION_HEADER)).toBe("ses_parent00000"); - expect(headerOf(0, PARENT_SESSION_ALT_HEADER)).toBe("ses_parent00000"); + expect(headerOf(0, PARENT_SESSION_HEADER)).toBe(PARENT_VALUE); + expect(headerOf(0, PARENT_SESSION_ALT_HEADER)).toBe(PARENT_VALUE); // Origin identity, including the turn's message id — the header 1.19.4's // `request.ts` sends beside the client and project headers. expect(headerOf(0, "x-opencode-client")).toBe("cli"); expect(headerOf(0, "x-opencode-project")).toBe("dsh-opencode"); - expect(headerOf(0, "x-opencode-request")).toBe( - "msg_e2e0000000abCDEFGHIJKLMNOP" - ); + expect(headerOf(0, "x-opencode-request")).toBe(REQUEST_VALUE); expect(headerOf(0, "user-agent")).toBe(OPENCODE_UA); }); @@ -135,14 +139,29 @@ describe.skipIf(!ENABLED)("patched fetch over a real socket", () => { expect(received[0]?.["user-agent"]).not.toBe(OPENCODE_UA); }); - it("keeps a caller's own valid session header", async () => { + it("a turn's own session id outranks a caller's valid header, which is kept outside a turn", async () => { + const caller = openCodeSessionIdFor("e2e-caller"); await als.run(turn(), () => patched(`${origin}/models`, { - headers: { [SESSION_HEADER]: "ses_caller00000" }, + headers: { [SESSION_HEADER]: caller }, }) ); // The turn's own value wins while a turn is active… - expect(headerOf(0, SESSION_HEADER)).toBe("ses_e2e00000000"); + expect(headerOf(0, SESSION_HEADER)).toBe(TURN_VALUE); + + // …and with no turn, the caller's valid id is what goes on the wire. A + // request outside a turn is claimed by its URL alone, so this patch names + // the listener as a gateway the way a deployment names a relay. + const byUrl = patchFetch( + fetch, + als, + resolveConfig({ gatewayUrls: [origin] }) + ); + await byUrl(`${origin}/models`, { headers: { [SESSION_HEADER]: caller } }); + expect(headerOf(1, "x-opencode-client"), "the request was claimed").toBe( + "cli" + ); + expect(headerOf(1, SESSION_HEADER)).toBe(caller); }); it("preserves the caller's unrelated headers", async () => { diff --git a/test/mock/plane-dispatch.mock.ts b/test/mock/plane-dispatch.mock.ts index 2b0f280e..00f8d20a 100644 --- a/test/mock/plane-dispatch.mock.ts +++ b/test/mock/plane-dispatch.mock.ts @@ -39,6 +39,7 @@ import { waitForPlaneOwner, } from "../support/host.ts"; import { clearCapturedApiKeys, headerValueFor } from "../support/src.ts"; +import { urlOf } from "../test-helpers.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { GO_KEY, ZEN_KEY } from "./helpers.ts"; @@ -126,14 +127,6 @@ const credentialOf = ( return ["authorization", `Bearer ${key}`]; }; -/** The URL a `fetch` input names. */ -const urlOf = (input: RequestInfo | URL): string => { - if (typeof input === "string") { - return input; - } - return input instanceof URL ? input.href : input.url; -}; - const keyOf = (plane: Plane): string => plane === "opencode" ? ZEN_KEY : GO_KEY; diff --git a/test/mock/protocol-endpoints.mock.ts b/test/mock/protocol-endpoints.mock.ts new file mode 100644 index 00000000..9fc20d18 --- /dev/null +++ b/test/mock/protocol-endpoints.mock.ts @@ -0,0 +1,293 @@ +/** + * Each endpoint family, through the plugin, over a socket. + * + * One request per protocol, streamed and unary, sent through `patchFetch` to + * the mock gateway with the plugin's own configuration: what is asserted is + * what the PLUGIN put on the wire — the protocol's own credential header and + * nothing else, the session it derived — and that the gateway, which refuses + * anything the vendor would refuse, answered it. The model of each case is the + * catalog's own (`fixtureModel`), so no case names a vendor id a refresh can + * retire. + * + * The gateway's own rules, asserted without the plugin, are not repeated + * here: a raw `fetch` carrying the right headers would pass them as well, so + * they test the mock rather than the plugin. + * + * @module test/mock/protocol-endpoints.mock + */ + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + ANTHROPIC_VERSION, + clearCapturedApiKeys, + OPENCODE_ZEN_CATALOG, + type PluginConfig, + resolveConfig, +} from "../support/src.ts"; +import { startMockGateway, type MockGateway } from "./gateway-server.ts"; +import { + fixtureModel, + gatewayHarness, + GO_KEY, + kindForProtocol, + lastRecorded, + MOCK_SESSION, + MOCK_TEXT, + pathForProtocol, + PROTOCOLS, + queryForProtocol, + sessionTurn, + wireHeaderFor, + wireValueFor, + ZEN_KEY, +} from "./helpers.ts"; + +let gateway: MockGateway; + +beforeAll(async () => { + gateway = await startMockGateway(); +}); + +afterAll(async () => { + await gateway.close(); +}); + +beforeEach(() => { + gateway.requests.length = 0; + clearCapturedApiKeys(); + vi.stubEnv("OPENCODE_API_KEY", ZEN_KEY); + vi.stubEnv("OPENCODE_GO_API_KEY", GO_KEY); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +const ZEN = "https://opencode.ai/zen/v1"; + +/** One turn's request through the plugin, inside a turn on `provider`. */ +const send = ( + url: string, + init: RequestInit, + options: { config?: PluginConfig; model?: string; provider?: string } = {} +): Promise => { + const harness = gatewayHarness( + gateway.origin, + resolveConfig(options.config ?? {}) + ); + return harness.als.run( + sessionTurn({ + ...(options.model === undefined ? {} : { model: options.model }), + provider: options.provider ?? "opencode", + }), + () => harness.fetch(url, init) + ); +}; + +const post = (body: unknown): RequestInit => ({ + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", +}); + +const MESSAGES = [{ content: "hi", role: "user" }]; + +/** The body one protocol's SDK sends, streamed or not. */ +const bodyFor = (protocol: string, model: string, stream: boolean): unknown => { + switch (protocol) { + case "anthropic-messages": { + return { max_tokens: 64, messages: MESSAGES, model, stream }; + } + case "google-generative-ai": { + return { contents: [{ parts: [{ text: "hi" }], role: "user" }] }; + } + case "openai-responses": { + return { input: [], model, stream }; + } + default: { + return { messages: MESSAGES, model, stream }; + } + } +}; + +describe("each protocol's endpoint, through the plugin", () => { + it.for(PROTOCOLS)( + "$protocol: streams and answers unary through the plugin with the protocol's own credential header", + async ({ protocol }) => { + // The mock serves the free tier on completions, where the core-tool + // fallback makes a free body one it admits. + const spec = fixtureModel( + "opencode", + protocol, + protocol === "openai-completions" ? "free" : "paid" + ); + const path = pathForProtocol(protocol, spec.id); + + const streamed = await send( + `${ZEN}${path}${queryForProtocol(protocol)}`, + post(bodyFor(protocol, spec.id, true)), + { model: spec.id } + ); + expect(streamed.status).toBe(200); + expect(streamed.headers.get("content-type")).toContain( + "text/event-stream" + ); + const events = await streamed.text(); + expect(events.length).toBeGreaterThan(0); + const recorded = lastRecorded(gateway); + expect(recorded.kind).toBe(kindForProtocol(protocol)); + expect(recorded.protocol).toBe(protocol); + expect(recorded.model).toBe(spec.id); + expect(recorded.path).toBe(`/zen/v1${path}`); + expect(recorded.headers[wireHeaderFor(protocol)]).toBe( + wireValueFor(protocol, ZEN_KEY) + ); + if (wireHeaderFor(protocol) !== "authorization") { + // One credential, on the header the protocol reads: the official + // client lets its SDK choose, and these SDKs send no Authorization. + expect(recorded.headers.authorization).toBeUndefined(); + } + if (protocol === "anthropic-messages") { + // The case sends no version of its own: the one on the wire is the + // plugin's fill, asserted here rather than read off the mock's 200. + expect(recorded.headers["anthropic-version"]).toBe(ANTHROPIC_VERSION); + } + expect(recorded.headers["x-opencode-session-id"]).toBe(MOCK_SESSION); + + const unaryPath = + protocol === "google-generative-ai" + ? `/models/${spec.id}:generateContent` + : path; + const unary = await send( + `${ZEN}${unaryPath}`, + post(bodyFor(protocol, spec.id, false)), + { model: spec.id } + ); + expect(unary.status).toBe(200); + expect(unary.headers.get("content-type")).toContain("application/json"); + expect(await unary.text()).toContain(MOCK_TEXT); + } + ); + + it("a placeholder in OPENCODE_API_KEY is not a credential: nothing is sent and the gateway answers 401", async () => { + vi.stubEnv("OPENCODE_API_KEY", "unused"); + vi.spyOn(console, "warn").mockImplementation(() => { + // the missing-credential report is request-shapes' to assert + }); + const spec = fixtureModel("opencode", "openai-responses", "paid"); + const response = await send( + `${ZEN}/responses`, + post(bodyFor("openai-responses", spec.id, true)), + { model: spec.id } + ); + expect(response.status).toBe(401); + const payload = (await response.json()) as { error: { type: string } }; + expect(payload.error.type).toBe("invalid_api_key"); + const recorded = lastRecorded(gateway); + expect(recorded.headers.authorization).toBeUndefined(); + expect(recorded.reject).toBe("credential"); + }); + + it.each(["/zen/go/v1/usage", "/usage"])( + "the Go plane's usage listing at %s carries the Go key", + async (path) => { + const response = await send( + `https://opencode.ai${path}`, + { method: "GET" }, + { provider: "opencode-go" } + ); + expect(response.status).toBe(200); + const recorded = lastRecorded(gateway); + expect(recorded.kind).toBe("usage"); + expect(recorded.headers.authorization).toBe(`Bearer ${GO_KEY}`); + } + ); +}); + +describe("the models listing, through the plugin", () => { + interface Listing { + data: { id: string; name?: string }[]; + object: string; + } + + const listing = async (enrichModels: boolean): Promise => { + const response = await send( + `${ZEN}/models`, + { method: "GET" }, + { config: { enrichModels } } + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/json"); + const recorded = lastRecorded(gateway); + expect(recorded.kind).toBe("models"); + expect(recorded.method).toBe("GET"); + expect(recorded.headers["x-opencode-session-id"]).toBe(MOCK_SESSION); + return (await response.json()) as Listing; + }; + + it("enriches GET /zen/v1/models over the socket", async () => { + const body = await listing(true); + expect(body.object).toBe("list"); + // The catalog seeds the listing, each row described by its own entry. + expect(body.data.length).toBeGreaterThanOrEqual( + OPENCODE_ZEN_CATALOG.length + ); + const [first] = OPENCODE_ZEN_CATALOG; + expect(body.data.find((row) => row.id === first?.id)?.name).toBe( + first?.name + ); + }); + + it("leaves the listing alone when enrichModels is false", async () => { + const body = await listing(false); + expect(body.object).toBe("list"); + // Exactly the two rows the gateway listed, undescribed. + expect(body.data.map((row) => row.id)).toEqual([ + OPENCODE_ZEN_CATALOG[0]?.id, + "mock-unknown-model", + ]); + expect(body.data.every((row) => row.name === undefined)).toBe(true); + }); +}); + +describe("the core-tool fallback, through the plugin", () => { + it("a paid model gets no core tools", async () => { + const spec = fixtureModel("opencode", "openai-responses", "paid"); + const response = await send( + `${ZEN}/responses`, + post(bodyFor("openai-responses", spec.id, true)), + { config: { injectCoreTools: true }, model: spec.id } + ); + expect(response.status).toBe(200); + const body = lastRecorded(gateway).body as { tools?: unknown[] }; + expect(body.tools).toBeUndefined(); + }); + + it("injectCoreTools: false injects nothing, and the gateway then refuses the free model with 403", async () => { + // Turning the fallback off is honoured — nothing is injected — and the + // consequence is visible rather than implied: a free-tier body with no + // tools answers `403 FreeTierError` (measured against the live gateway, and + // modelled by the mock). The configuration is the operator's choice; the + // refusal is the gateway's. + const spec = fixtureModel("opencode", "openai-completions", "free"); + const response = await send( + `${ZEN}/chat/completions`, + post(bodyFor("openai-completions", spec.id, true)), + { config: { injectCoreTools: false }, model: spec.id } + ); + expect(response.status).toBe(403); + const body = lastRecorded(gateway).body as { tools?: unknown[] }; + expect(body.tools).toBeUndefined(); + }); +}); diff --git a/test/e2e/refusal-classification.e2e.ts b/test/mock/refusal-classification.mock.ts similarity index 97% rename from test/e2e/refusal-classification.e2e.ts rename to test/mock/refusal-classification.mock.ts index 3bd16d37..d5f588ed 100644 --- a/test/e2e/refusal-classification.e2e.ts +++ b/test/mock/refusal-classification.mock.ts @@ -21,7 +21,10 @@ * the verdicts depend on, in their order — a vendor move fails here instead of * being absorbed by a stale copy. * - * Opt-in (`OPENCODE_E2E=1`), like the sibling header suite: it binds a port. + * It binds a loopback port and nothing else, like the sibling header suite, + * so it runs in the mock suite on every push. + * + * @module test/mock/refusal-classification.mock */ import assert from "node:assert/strict"; @@ -34,12 +37,11 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { type ActiveTurnState, + openCodeSessionIdFor, patchFetch, resolveConfig, } from "../support/src.ts"; -const ENABLED = process.env.OPENCODE_E2E === "1"; - /** * The first case reads the INSTALLED `@deepseek-ai/dsh-llm-pi-ai` bundle and * pins the branch order the contract below relies on: AUTH is tested FIRST, @@ -167,14 +169,14 @@ interface Refusal { error: { message: string; type: string }; } -describe.skipIf(!ENABLED)("refusal classification over a real socket", () => { +describe("refusal classification over a real socket", () => { const als = new AsyncLocalStorage(); const patched = patchFetch(fetch, als, resolveConfig({})); const turn = (overrides: Partial = {}): ActiveTurnState => ({ model: "gpt-5.4", provider: "opencode", - value: "ses_e2e00000000", + value: openCodeSessionIdFor("e2e-refusal"), ...overrides, }); diff --git a/test/mock/request-shapes.mock.ts b/test/mock/request-shapes.mock.ts index 65d299de..4b3cc457 100644 --- a/test/mock/request-shapes.mock.ts +++ b/test/mock/request-shapes.mock.ts @@ -35,21 +35,31 @@ import { SESSION_AFFINITY_HEADER, SESSION_HEADER, clearCapturedApiKeys, + isOpenCodeSessionId, + newOpenCodeMessageId, + openCodeSessionIdFor, patchFetch, resolveConfig, } from "../support/src.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { firstSpecFor, + fixtureModel, + gatewayHarness, GO_KEY, keyFor, + lastRecorded, MOCK_SESSION, sessionTurn, ZEN_KEY, } from "./helpers.ts"; import type { GatewayRequest, MockReply } from "./opencode-gateway.ts"; -const PARENT_VALUE = "ses_eb1e9e706ffdZYXWVUTSRQPONM"; +const PARENT_VALUE = openCodeSessionIdFor("mock-parent"); + +/** A paid Responses model and a free completions one, from the catalog. */ +const PAID = fixtureModel("opencode", "openai-responses", "paid").id; +const FREE = fixtureModel("opencode", "openai-completions", "free").id; const als = new AsyncLocalStorage(); @@ -100,26 +110,17 @@ const post = ( method: "POST", }); -const recorded = (): GatewayRequest => { - const last = gateway.requests.at(-1); - if (last === undefined) { - throw new Error("the mock gateway saw no request"); - } - return last; -}; +const recorded = (): GatewayRequest => lastRecorded(gateway); const responsesUrl = (): string => `${gateway.origin}/zen/v1/responses`; describe("the request shape per configuration", () => { it("sends the session, origin, project, UA and Zen key under the defaults", async () => { + const requestValue = newOpenCodeMessageId(); const response = await als.run( - sessionTurn({ project: "mock-project" }), + sessionTurn({ project: "mock-project", requestValue }), () => - post( - patchedWith(resolveConfig()), - responsesUrl(), - responsesBody("gpt-5.4") - ) + post(patchedWith(resolveConfig()), responsesUrl(), responsesBody(PAID)) ); expect(response.status).toBe(200); const request = recorded(); @@ -129,6 +130,8 @@ describe("the request shape per configuration", () => { expect(request.headers["x-opencode-session-id"]).toBe(MOCK_SESSION); expect(request.headers["x-opencode-client"]).toBe("cli"); expect(request.headers["x-opencode-project"]).toBe("mock-project"); + // The turn's message id, for the gateway's per-request metrics. + expect(request.headers["x-opencode-request"]).toBe(requestValue); expect(request.headers["user-agent"]).toBe(OPENCODE_UA); expect(request.headers.authorization).toBe(`Bearer ${ZEN_KEY}`); expect(request.headers["x-opencode-org-id"]).toBeUndefined(); @@ -137,23 +140,35 @@ describe("the request shape per configuration", () => { }); it("falls the project back to global when the turn carries none", async () => { + // Nor a message id: an absent header is correct then, and an empty + // `x-opencode-request:` is a value the client cannot send. await als.run(sessionTurn(), () => - post( - patchedWith(resolveConfig()), - responsesUrl(), - responsesBody("gpt-5.4") - ) + post(patchedWith(resolveConfig()), responsesUrl(), responsesBody(PAID)) ); expect(recorded().headers["x-opencode-project"]).toBe("global"); + expect(recorded().headers["x-opencode-request"]).toBeUndefined(); + }); + + it("outside a turn, the patch derives a session id the gateway admits", async () => { + // The usage and discovery calls run outside any tracked turn, and the + // gateway refuses a request with no session or one outside its band. With + // no turn to claim it, the request is claimed by its gateway URL, so it goes + // to the vendor's origin through the harness that points it at the mock. + const harness = gatewayHarness(gateway.origin, resolveConfig()); + const response = await post( + harness.fetch, + "https://opencode.ai/zen/v1/responses", + responsesBody(PAID) + ); + expect(response.status).toBe(200); + expect( + isOpenCodeSessionId(recorded().headers["x-opencode-session-id"]) + ).toBe(true); }); it("carries both parent-lineage headers when the turn has a parent", async () => { await als.run(sessionTurn({ parentValue: PARENT_VALUE }), () => - post( - patchedWith(resolveConfig()), - responsesUrl(), - responsesBody("gpt-5.4") - ) + post(patchedWith(resolveConfig()), responsesUrl(), responsesBody(PAID)) ); const request = recorded(); expect(request.headers[PARENT_SESSION_HEADER]).toBe(PARENT_VALUE); @@ -163,7 +178,7 @@ describe("the request shape per configuration", () => { it("adds the affinity pair for a relay provider and keeps the session", async () => { const config = resolveConfig({ gatewayUrls: [gateway.origin] }); await als.run(sessionTurn({ provider: "mock-relay" }), () => - post(patchedWith(config), responsesUrl(), responsesBody("gpt-5.4")) + post(patchedWith(config), responsesUrl(), responsesBody(PAID)) ); const request = recorded(); expect(request.headers[SESSION_HEADER]).toBe(MOCK_SESSION); @@ -173,16 +188,19 @@ describe("the request shape per configuration", () => { }); it("omits the origin headers when injectOriginHeaders is off", async () => { - await als.run(sessionTurn(), () => + await als.run(sessionTurn({ requestValue: newOpenCodeMessageId() }), () => post( patchedWith(resolveConfig({ injectOriginHeaders: false })), responsesUrl(), - responsesBody("gpt-5.4") + responsesBody(PAID) ) ); const request = recorded(); expect(request.headers["x-opencode-client"]).toBeUndefined(); expect(request.headers["x-opencode-project"]).toBeUndefined(); + // The message id belongs to the same switch, and the turn HAS one here: + // the absence is the switch, not a missing value. + expect(request.headers["x-opencode-request"]).toBeUndefined(); expect(request.headers[SESSION_HEADER]).toBe(MOCK_SESSION); expect(request.headers["user-agent"]).toBe(OPENCODE_UA); }); @@ -192,7 +210,7 @@ describe("the request shape per configuration", () => { post( patchedWith(resolveConfig({ injectProject: false })), responsesUrl(), - responsesBody("gpt-5.4") + responsesBody(PAID) ) ); const request = recorded(); @@ -205,7 +223,7 @@ describe("the request shape per configuration", () => { post( patchedWith(resolveConfig({ originClient: "dsh-mock/1.0" })), responsesUrl(), - responsesBody("gpt-5.4") + responsesBody(PAID) ) ); expect(recorded().headers["x-opencode-client"]).toBe("dsh-mock/1.0"); @@ -216,7 +234,7 @@ describe("the request shape per configuration", () => { post( patchedWith(resolveConfig({ userAgent: "mock-agent/9.9" })), responsesUrl(), - responsesBody("gpt-5.4") + responsesBody(PAID) ) ); expect(recorded().headers["user-agent"]).toBe("mock-agent/9.9"); @@ -227,7 +245,7 @@ describe("the request shape per configuration", () => { post( patchedWith(resolveConfig({ injectUserAgent: false })), responsesUrl(), - responsesBody("gpt-5.4") + responsesBody(PAID) ) ); expect(recorded().headers["user-agent"]).not.toBe(OPENCODE_UA); @@ -238,60 +256,12 @@ describe("the request shape per configuration", () => { post( patchedWith(resolveConfig({ credentialOrg: "wrk_mock" })), responsesUrl(), - responsesBody("gpt-5.4") + responsesBody(PAID) ) ); expect(recorded().headers["x-opencode-org-id"]).toBe("wrk_mock"); }); - it("authenticates the Messages endpoint with x-api-key alone", async () => { - const spec = firstSpecFor("opencode", "@ai-sdk/anthropic"); - if (spec === undefined) { - throw new Error("the zen catalog serves no anthropic model"); - } - const response = await als.run(sessionTurn(), () => - post( - patchedWith(resolveConfig()), - `${gateway.origin}/zen/v1/messages?beta=true`, - { - max_tokens: 64, - messages: [{ content: "hi", role: "user" }], - model: spec.id, - stream: true, - }, - // The Anthropic protocol identifies itself by version, and the request - // this test stands in for is one the SDK adapter produced — so it carries - // the header the adapter adds. The mock enforces it. - { "anthropic-version": "2023-06-01" } - ) - ); - expect(response.status).toBe(200); - const request = recorded(); - expect(request.headers["x-api-key"]).toBe(ZEN_KEY); - // The official client hands the key to the SDK and lets it choose the header - // (`packages/opencode/src/provider/provider.ts:240`); an Anthropic request - // carries `x-api-key` and no `Authorization`. - expect(request.headers.authorization).toBeUndefined(); - }); - - it("authenticates the Google endpoint with x-goog-api-key", async () => { - const spec = firstSpecFor("opencode", "@ai-sdk/google"); - if (spec === undefined) { - throw new Error("the zen catalog serves no Google model"); - } - const response = await als.run(sessionTurn(), () => - post( - patchedWith(resolveConfig()), - `${gateway.origin}/zen/v1/models/${spec.id}:streamGenerateContent?alt=sse`, - { contents: [{ parts: [{ text: "hi" }], role: "user" }] } - ) - ); - expect(response.status).toBe(200); - const request = recorded(); - expect(request.headers["x-goog-api-key"]).toBe(ZEN_KEY); - expect(request.headers.authorization).toBeUndefined(); - }); - it("adds the core-tool schemas for a free-tier completions model", async () => { // The vendor's per-model protocol table no longer lists any free model on // /responses (the last one was deprecated upstream), so the free fixture @@ -303,7 +273,7 @@ describe("the request shape per configuration", () => { `${gateway.origin}/zen/v1/chat/completions`, { messages: [{ content: "hi", role: "user" }], - model: "beam-free", + model: FREE, } ) ); @@ -318,50 +288,6 @@ describe("the request shape per configuration", () => { expect(names).toContain("bash"); }); - it("passes a 403 refusal through with the gateway's status and message", async () => { - // The status belongs to the gateway. `Model access is disabled` is an - // entitlement answer, and rewriting it to 400 — to steer DSH's classifier away - // from its AUTH label — changed a status this plugin does not own. The - // classifier's label is its reading; the message underneath is accurate. - forced = { - body: JSON.stringify({ - error: { - message: "Upstream request failed: Model access is disabled", - type: "server_error", - }, - }), - status: 403, - }; - const response = await als.run(sessionTurn(), () => - post( - patchedWith(resolveConfig()), - responsesUrl(), - responsesBody("gpt-5.4") - ) - ); - expect(response.status).toBe(403); - const payload = (await response.json()) as { - error: { message: string; type: string }; - }; - expect(payload.error.type).toBe("server_error"); - expect(payload.error.message).toContain("Model access is disabled"); - }); - - it("passes a 401 through", async () => { - forced = { - body: JSON.stringify({ error: { message: "Invalid API key" } }), - status: 401, - }; - const response = await als.run(sessionTurn(), () => - post( - patchedWith(resolveConfig()), - responsesUrl(), - responsesBody("gpt-5.4") - ) - ); - expect(response.status).toBe(401); - }); - it("sends no credential and warns when none resolves", async () => { vi.stubEnv("OPENCODE_API_KEY", ""); const warnings: unknown[][] = []; @@ -369,11 +295,7 @@ describe("the request shape per configuration", () => { warnings.push(args); }); const response = await als.run(sessionTurn(), () => - post( - patchedWith(resolveConfig()), - responsesUrl(), - responsesBody("gpt-5.4") - ) + post(patchedWith(resolveConfig()), responsesUrl(), responsesBody(PAID)) ); expect(response.status).toBe(401); expect(recorded().headers.authorization).toBeUndefined(); @@ -383,8 +305,78 @@ describe("the request shape per configuration", () => { expect(String(warnings[0]?.[0])).toContain( "no credential resolved for opencode" ); + + // The Go plane the same way: no Go key, nothing sent, and the gateway's 401. + vi.stubEnv("OPENCODE_GO_API_KEY", ""); + const goModel = fixtureModel("opencode-go", "openai-responses", "paid").id; + const go = await als.run( + sessionTurn({ model: goModel, provider: "opencode-go" }), + () => + post( + patchedWith(resolveConfig()), + `${gateway.origin}/inference/go/openai/v1/responses`, + responsesBody(goModel) + ) + ); + expect(go.status).toBe(401); + expect(recorded().plane).toBe("opencode-go"); + expect(recorded().headers.authorization).toBeUndefined(); + expect(recorded().reject).toBe("credential"); }); + /** + * **A refusal passes through untouched, with the gateway's own status and + * words.** The status belongs to the gateway. `Model access is disabled` is + * an entitlement answer, and rewriting it to 400 — to steer DSH's classifier + * away from its AUTH label — changed a status this plugin does not own. The + * classifier's label is its reading; the message underneath is accurate. + */ + it.each([ + [ + 403, + "server_error", + "Upstream request failed: Model access is disabled", + "/zen/v1/responses", + ], + [401, "invalid_api_key", "Invalid API key", "/zen/v1/responses"], + [ + 429, + "rate_limit_exceeded", + "Rate limit exceeded. Please wait 10s.", + "/zen/v1/responses", + ], + [502, "bad_gateway", "Bad Gateway", "/zen/v1/responses"], + [ + 403, + "entitlement_error", + "EntitlementError: subscription required", + "/zen/go/v1/usage", + ], + ] as const)( + "passes a %i %s through with the gateway's status and message", + async (status, type, message, path) => { + forced = { body: JSON.stringify({ error: { message, type } }), status }; + const isUsage = path.endsWith("/usage"); + const response = await als.run( + sessionTurn(isUsage ? { provider: "opencode-go" } : {}), + () => + isUsage + ? patchedWith(resolveConfig())(`${gateway.origin}${path}`) + : post( + patchedWith(resolveConfig()), + `${gateway.origin}${path}`, + responsesBody(PAID) + ) + ); + expect(response.status).toBe(status); + const payload = (await response.json()) as { + error: { message: string; type: string }; + }; + expect(payload.error.type).toBe(type); + expect(payload.error.message).toBe(message); + } + ); + it("uses the Go key for the Go plane", async () => { const spec = firstSpecFor("opencode-go", "@ai-sdk/openai"); if (spec === undefined) { @@ -408,168 +400,6 @@ describe("the request shape per configuration", () => { }); }); -/** - * The gateway's own rules, asserted against the MOCK rather than through the - * plugin. - * - * Every case here is a measurement taken against the live gateway and then - * reproduced by `handleGatewayRequest`. They are what would have caught the - * session-id bug years earlier: our ids were syntactically valid and the gateway - * refused them, which nothing in this suite modelled until now. - */ -const IN_BAND_SESSION = "ses_eb1e9e706ffeCDEFGHIJKLMNOP"; - -describe("the gateway's rules, as measured", () => { - const IN_BAND = IN_BAND_SESSION; - const OUT_OF_BAND = "ses_0123456789abCDEFGHIJKLMNOP"; - const TOOLS = [ - { - function: { name: "read", parameters: { type: "object" } }, - type: "function", - }, - ]; - - const send = ( - session: string, - body: Record - ): Promise => - fetch(`${gateway.origin}/zen/v1/chat/completions`, { - body: JSON.stringify(body), - headers: { - authorization: `Bearer ${ZEN_KEY}`, - "content-type": "application/json", - "x-opencode-session": session, - "x-opencode-session-id": session, - }, - method: "POST", - }); - - const freeBody = (tools?: unknown): Record => ({ - max_tokens: 32, - messages: [{ content: "hi", role: "user" }], - model: "beam-free", - ...(tools === undefined ? {} : { tools }), - }); - - it("refuses a session id outside the admitted band, whatever its shape", async () => { - const response = await send(OUT_OF_BAND, freeBody(TOOLS)); - expect(response.status).toBe(403); - const payload = (await response.json()) as { - error: { type: string }; - }; - const { error } = payload; - expect(error.type).toBe("FreeTierError"); - }); - - it("admits the same request when the session is in the band", async () => { - const response = await send(IN_BAND, freeBody(TOOLS)); - expect(response.status).toBe(200); - }); - - it("refuses a free-tier model whose body carries no tools", async () => { - const response = await send(IN_BAND, freeBody()); - expect(response.status).toBe(403); - const payload = (await response.json()) as { - error: { type: string }; - }; - expect(payload.error.type).toBe("FreeTierError"); - }); -}); - -describe("each protocol's own contract, asserted against the mock", () => { - const ZEN_ANTHROPIC_PATH = "/zen/v1/messages?beta=true"; - const spec = firstSpecFor("opencode", "@ai-sdk/anthropic"); - const googleSpec = firstSpecFor("opencode", "@ai-sdk/google"); - - const raw = ( - path: string, - body: Record, - headers: Record - ): Promise => - fetch(`${gateway.origin}${path}`, { - body: JSON.stringify(body), - headers: { - "content-type": "application/json", - "x-opencode-session": IN_BAND_SESSION, - "x-opencode-session-id": IN_BAND_SESSION, - ...headers, - }, - method: "POST", - }); - - it("requires anthropic-version on a Messages call", async () => { - if (spec === undefined) { - throw new Error("the zen catalog serves no anthropic model"); - } - const response = await raw( - ZEN_ANTHROPIC_PATH, - { - max_tokens: 64, - messages: [{ content: "hi", role: "user" }], - model: spec.id, - stream: true, - }, - { "x-api-key": ZEN_KEY } - ); - expect(response.status).toBe(400); - }); - - it("requires the Messages auth header to be x-api-key, not Authorization", async () => { - if (spec === undefined) { - throw new Error("the zen catalog serves no anthropic model"); - } - // The official client sends `x-api-key` and no `Authorization` - // (`provider/provider.ts:240`); a Bearer-only request has no credential on the - // header this protocol reads. - const response = await raw( - ZEN_ANTHROPIC_PATH, - { - max_tokens: 64, - messages: [{ content: "hi", role: "user" }], - model: spec.id, - stream: true, - }, - { - "anthropic-version": "2023-06-01", - authorization: `Bearer ${ZEN_KEY}`, - } - ); - expect(response.status).toBe(401); - }); - - it("requires Anthropic-shaped bodies: max_tokens and messages", async () => { - if (spec === undefined) { - throw new Error("the zen catalog serves no anthropic model"); - } - const response = await raw( - ZEN_ANTHROPIC_PATH, - { model: spec.id, stream: true }, - { "anthropic-version": "2023-06-01", "x-api-key": ZEN_KEY } - ); - expect(response.status).toBe(400); - }); - - it("requires x-goog-api-key and a contents array on a Google call", async () => { - if (googleSpec === undefined) { - throw new Error("the zen catalog serves no Google model"); - } - const path = `/zen/v1/models/${googleSpec.id}:streamGenerateContent?alt=sse`; - const withoutKey = await raw( - path, - { contents: [{ parts: [{ text: "hi" }], role: "user" }] }, - { authorization: `Bearer ${ZEN_KEY}` } - ); - expect(withoutKey.status).toBe(401); - - const withoutContents = await raw( - path, - { messages: [{ content: "hi", role: "user" }] }, - { "x-goog-api-key": ZEN_KEY } - ); - expect(withoutContents.status).toBe(400); - }); -}); - describe("keyless: free models still answer", () => { it("serves a free model when no credential exists anywhere", async () => { // The deployment the plugin has to survive: an `apiKeyEnv` that is declared @@ -586,7 +416,7 @@ describe("keyless: free models still answer", () => { `${gateway.origin}/zen/v1/chat/completions`, { messages: [{ content: "hi", role: "user" }], - model: "beam-free", + model: FREE, stream: true, tools: [ { diff --git a/test/mock/testing-kit.ts b/test/mock/testing-kit.ts deleted file mode 100644 index 4d4f1126..00000000 --- a/test/mock/testing-kit.ts +++ /dev/null @@ -1,608 +0,0 @@ -/** - * Comprehensive Testing Kit & Simulator Utilities for dsh-opencode-patch. - * - * Provides full end-to-end testing fixtures and simulators covering: - * - Authentication Methods (API key, OAuth grants, local SQLite store across channels) - * - API Endpoints (LLM streaming/unary, models catalog, console auth, usage) - * - Response Types (SSE streams, unary JSON, error formats 400/401/403/404/429/500, tool fallbacks) - * - OpenCode Client Types & Channels (stable, insider, dev, custom, user-agents, session affinity) - * - DeepSeek Harness Versions (legacy namespace loader vs modern fiber-only loader, Cordis context) - * - * @module test/mock/testing-kit - */ - -import { AsyncLocalStorage } from "node:async_hooks"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; -import os from "node:os"; -import nodePath from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { - type ActiveTurnState, - type FetchPatchHooks, - OPENCODE_UA, - PARENT_SESSION_ALT_HEADER, - PARENT_SESSION_HEADER, - type PluginConfig, - SESSION_AFFINITY_HEADER, - patchFetch, - resolveConfig, -} from "../support/src.ts"; -import { startMockGateway, type MockGateway } from "./gateway-server.ts"; -import { - GO_KEY, - MOCK_SESSION, - MOCK_TEXT, - ZEN_KEY, - type TestPlane, -} from "./helpers.ts"; -import type { - GatewayRequest, - OpenCodeGatewayOptions, -} from "./opencode-gateway.ts"; - -export { MOCK_SESSION, MOCK_TEXT, GO_KEY, ZEN_KEY }; - -const GATEWAY_ORIGIN = "https://opencode.ai"; - -const stringifyInput = (input: RequestInfo | URL): string => { - if (typeof input === "string") { - return input; - } - if (input instanceof URL) { - return input.href; - } - return input.url; -}; - -const redirectUrl = (url: string, origin: string): string => { - if (url.startsWith(GATEWAY_ORIGIN)) { - return url.replace(GATEWAY_ORIGIN, origin); - } - if (url.startsWith("http://mock.invalid")) { - return url.replace("http://mock.invalid", origin); - } - if (url.startsWith("/")) { - return `${origin}${url}`; - } - return url; -}; - -/* -------------------------------------------------------------------------- */ -/* Auth Simulator */ -/* -------------------------------------------------------------------------- */ - -export type AuthMethodType = "apiKey" | "localStore" | "missing" | "oauthGrant"; - -export interface LocalStoreChannelOptions { - activeOrgId?: string; - channel?: string; - credentials?: readonly { - active?: number; - integrationId: string; - label?: string; - value: string; - }[]; - customDbName?: string; - email?: string; - platform?: "linux" | "macos" | "windows"; - token?: string; -} - -export interface LocalStoreHandle { - cleanup: () => void; - dbPath: string; - env: Record; - rootDir: string; -} - -export const AuthSimulator = { - /** - * Create OAuth grant details for test authentication hooks. - */ - createOAuthGrant( - plane: TestPlane = "opencode", - options: { - accessToken?: string; - email?: string; - orgId?: string; - refreshToken?: string; - } = {} - ): { token: string; orgId: string; email: string; plane: string } { - const token = options.accessToken ?? "mock-oauth-access-token-xyz"; - const orgId = options.orgId ?? "wrk_mock_org_456"; - const email = options.email ?? "developer@example.com"; - return { email, orgId, plane, token }; - }, - - /** - * Build a real SQLite database for any OpenCode channel, platform, and path layout. - */ - createLocalStore(options: LocalStoreChannelOptions = {}): LocalStoreHandle { - const rootDir = mkdtempSync( - nodePath.join(os.tmpdir(), "opencode-testing-kit-") - ); - const { channel = "stable", customDbName } = options; - - let dbFileName: string; - if (customDbName !== undefined) { - dbFileName = customDbName; - } else if ( - channel === "stable" || - channel === "latest" || - channel === "dev" || - channel === "beta" - ) { - dbFileName = "opencode.db"; - } else { - dbFileName = `opencode-${channel.replaceAll(/[^a-zA-Z0-9._-]/gu, "-")}.db`; - } - - const opencodeDir = nodePath.join(rootDir, ".local", "share", "opencode"); - mkdirSync(opencodeDir, { recursive: true }); - - const dbPath = nodePath.join(opencodeDir, dbFileName); - const database = new DatabaseSync(dbPath); - - database.exec(` - CREATE TABLE IF NOT EXISTS credential ( - id TEXT PRIMARY KEY, - integration_id TEXT, - label TEXT, - value TEXT, - connector_id TEXT, - method_id TEXT, - active INTEGER, - time_created INTEGER, - time_updated INTEGER - ); - CREATE TABLE IF NOT EXISTS account ( - id TEXT PRIMARY KEY, - email TEXT, - url TEXT, - access_token TEXT, - refresh_token TEXT, - token_expiry INTEGER, - time_created INTEGER, - time_updated INTEGER - ); - CREATE TABLE IF NOT EXISTS account_state ( - id INTEGER PRIMARY KEY, - active_account_id TEXT, - active_org_id TEXT - ); - `); - - // Insert credentials - const credentials: readonly { - active?: number; - integrationId: string; - label?: string; - value: string; - }[] = options.credentials ?? [ - { - active: 1, - integrationId: "opencode", - label: "OpenCode Zen Key", - value: JSON.stringify({ key: ZEN_KEY, type: "api_key" }), - }, - { - active: 1, - integrationId: "opencode-go", - label: "OpenCode Go Key", - value: JSON.stringify({ key: GO_KEY, type: "api_key" }), - }, - ]; - - const insertCred = database.prepare( - "INSERT INTO credential (id, integration_id, label, value, active) VALUES (?, ?, ?, ?, ?)" - ); - for (const [index, cred] of credentials.entries()) { - insertCred.run( - `cred-${index}`, - cred.integrationId, - cred.label ?? "Key", - cred.value, - cred.active ?? 1 - ); - } - - // Insert account - const token = options.token ?? "mock-store-access-token"; - const email = options.email ?? "stored-user@example.com"; - const orgId = options.activeOrgId ?? "wrk_store_org_789"; - - database - .prepare( - "INSERT INTO account (id, email, access_token, refresh_token, token_expiry) VALUES (?, ?, ?, ?, ?)" - ) - .run( - "acc_store_1", - email, - token, - "mock-refresh-token", - Date.now() + 3_600_000 - ); - - database - .prepare( - "INSERT INTO account_state (id, active_account_id, active_org_id) VALUES (?, ?, ?)" - ) - .run(1, "acc_store_1", orgId); - - database.close(); - - const env: Record = { - HOME: rootDir, - XDG_DATA_HOME: nodePath.join(rootDir, ".local", "share"), - }; - if (channel !== "stable") { - env.OPENCODE_CHANNEL = channel; - } - if (customDbName !== undefined) { - env.OPENCODE_DB = dbPath; - } - - return { - cleanup: () => { - try { - rmSync(rootDir, { force: true, recursive: true }); - } catch { - // ignore - } - }, - dbPath, - env, - rootDir, - }; - }, -} as const; - -/* -------------------------------------------------------------------------- */ -/* Client Simulator */ -/* -------------------------------------------------------------------------- */ - -export type ClientGeneration = "1.19.4" | "2.x"; - -export interface ClientSimulatorOptions { - channel?: string; - clientType?: "cli" | "desktop" | "web" | "custom"; - generation?: ClientGeneration; - parentSessionId?: string; - project?: string; - sessionId?: string; - userAgent?: string; -} - -export class ClientSimulator { - readonly options: ClientSimulatorOptions; - readonly sessionId: string; - readonly generation: ClientGeneration; - - constructor(options: ClientSimulatorOptions = {}) { - this.options = options; - this.sessionId = options.sessionId ?? MOCK_SESSION; - this.generation = options.generation ?? "1.19.4"; - } - - /** - * Generate request headers representing this client type and generation. - */ - buildHeaders(overrides: Record = {}): Record { - const headers: Record = { - "content-type": "application/json", - "user-agent": this.options.userAgent ?? OPENCODE_UA, - "x-opencode-session": this.sessionId, - "x-opencode-session-id": this.sessionId, - ...overrides, - }; - - if (this.options.clientType !== undefined) { - headers["x-opencode-client"] = this.options.clientType; - } - - if (this.generation === "2.x") { - headers[SESSION_AFFINITY_HEADER] = this.sessionId; - } - - if (this.options.parentSessionId !== undefined) { - headers[PARENT_SESSION_HEADER] = this.options.parentSessionId; - headers[PARENT_SESSION_ALT_HEADER] = this.options.parentSessionId; - } - - if (this.options.project !== undefined) { - headers["x-opencode-project"] = this.options.project; - } - - if (this.options.channel !== undefined) { - headers["x-opencode-channel"] = this.options.channel; - } - - return headers; - } - - /** - * Turn state matching this client's active session. - */ - asTurnState(overrides: Partial = {}): ActiveTurnState { - return { - model: "gpt-5.4", - parentValue: this.options.parentSessionId, - project: this.options.project ?? "testing-kit-project", - provider: "opencode", - value: this.sessionId, - ...overrides, - }; - } -} - -/* -------------------------------------------------------------------------- */ -/* Harness Simulator */ -/* -------------------------------------------------------------------------- */ - -export type HarnessLoaderGeneration = - | "degraded" - | "fiber-only" - | "legacy-namespace"; - -export interface HarnessSimulatorOptions { - baseUrl?: string; - loaderGeneration?: HarnessLoaderGeneration; - remotes?: Record; - services?: Record; -} - -export interface MockCordisContext extends Record { - baseUrl: string; - get: (key: string) => unknown; - inject: ( - deps: readonly string[], - callback: (scope: unknown) => unknown - ) => unknown; - loader: { entries: () => unknown[] }; - remote: Record; -} - -export class HarnessSimulator { - readonly options: HarnessSimulatorOptions; - - constructor(options: HarnessSimulatorOptions = {}) { - this.options = options; - } - - /** - * Create a mock Cordis context matching a specific DeepSeek Harness version and loader. - */ - createMockContext(): { - ctx: MockCordisContext; - loaderEntries: unknown[]; - } { - const services = new Map( - Object.entries(this.options.services ?? {}) - ); - const remotes = new Map( - Object.entries(this.options.remotes ?? {}) - ); - - let loaderEntries: unknown[] = []; - if (this.options.loaderGeneration === "legacy-namespace") { - loaderEntries = [ - { - name: "@deepseek-ai/dsh-llm-pi-ai", - namespace: { - PiAiAdapter: class MockPiAiAdapter { - readonly adapterId = "mock-adapter"; - }, - apply() { - return true; - }, - inject: ["llm"], - name: "llm-pi-ai", - }, - options: { name: "@deepseek-ai/dsh-llm-pi-ai" }, - }, - ]; - } else if (this.options.loaderGeneration === "fiber-only") { - loaderEntries = [ - { - fiber: { - inject: { llm: null }, - runtime: { - callback() { - return true; - }, - }, - }, - options: { name: "@deepseek-ai/dsh-llm-pi-ai" }, - }, - ]; - } else if (this.options.loaderGeneration === "degraded") { - loaderEntries = []; - } - - const loader = { - entries: () => loaderEntries, - }; - - const ctx: MockCordisContext = { - baseUrl: this.options.baseUrl ?? new URL("../../", import.meta.url).href, - get: (key: string) => { - if (key === "loader") return loader; - return services.get(key); - }, - inject: ( - _deps: readonly string[], - callback: (scope: unknown) => unknown - ) => callback(ctx), - loader, - remote: Object.fromEntries(remotes.entries()), - ...Object.fromEntries(services.entries()), - }; - - return { ctx, loaderEntries }; - } -} - -/* -------------------------------------------------------------------------- */ -/* Matrix Runner */ -/* -------------------------------------------------------------------------- */ - -export interface MatrixCase { - authMethod: AuthMethodType; - endpoint: string; - expectedStatus: number; - family: string; - model?: string; - plane: TestPlane; - protocol?: string; - responseType: "error" | "streaming" | "unary"; -} - -export interface GatewayExecutionResult { - /** The raw client Response object. */ - readonly clientResponse: Response; - /** Status code from the client response. */ - readonly status: number; - /** Headers from the client response. */ - readonly headers: Headers; - /** Whether the response status is 2xx. */ - readonly ok: boolean; - /** Parsed JSON response body, if valid JSON. */ - readonly parsedBody?: unknown; - /** The request as recorded by the gateway. */ - readonly recordedRequest?: GatewayRequest; - /** Semantic alias for recordedRequest. */ - readonly request?: GatewayRequest; - /** Raw text of the response body. */ - readonly text: string; - /** Helper to read parsed body with type safety. */ - readonly json: (validate?: (data: unknown) => T) => T; - /** Optional captured error. */ - readonly error?: unknown; -} - -export type MatrixExecutionResult = GatewayExecutionResult; - -export class GatewayTestRunner { - private gateway!: MockGateway; - private readonly als: AsyncLocalStorage; - - constructor() { - this.als = new AsyncLocalStorage(); - } - - async start(options: OpenCodeGatewayOptions = {}): Promise { - this.gateway = await startMockGateway(options); - } - - async close(): Promise { - if (this.gateway) { - await this.gateway.close(); - } - } - - get origin(): string { - return this.gateway.origin; - } - - get requests(): GatewayRequest[] { - return this.gateway.requests; - } - - clearRequests(): void { - if (this.gateway) { - this.gateway.requests.length = 0; - } - } - - /** - * Run one request through patchFetch, capturing both client and gateway sides. - */ - async execute( - plane: TestPlane, - path: string, - options: { - authHeaders?: Record; - body?: unknown; - clientSimulator?: ClientSimulator; - config?: PluginConfig; - hooks?: FetchPatchHooks; - method?: string; - turnState?: ActiveTurnState | null; - } = {} - ): Promise { - const config = resolveConfig(options.config ?? {}); - const client = options.clientSimulator ?? new ClientSimulator(); - const turnState = - options.turnState === null - ? undefined - : (options.turnState ?? client.asTurnState({ provider: plane })); - - const realFetch = globalThis.fetch; - - const redirectingFetch = (async ( - input: RequestInfo | URL, - init?: RequestInit - ) => { - const url = stringifyInput(input); - const redirected = redirectUrl(url, this.gateway.origin); - return realFetch(redirected, init); - }) as typeof fetch; - - const patched = patchFetch( - redirectingFetch, - this.als, - config, - options.hooks - ); - - const fullUrl = path.startsWith("http") - ? path - : `${GATEWAY_ORIGIN}${path.startsWith("/") ? path : `/${path}`}`; - - const headers = client.buildHeaders(options.authHeaders); - - const method = - options.method ?? (options.body === undefined ? "GET" : "POST"); - - let clientResponse!: Response; - let text = ""; - let parsedBody: unknown; - - const runFetch = async () => { - clientResponse = await patched(fullUrl, { - body: - options.body === undefined ? undefined : JSON.stringify(options.body), - headers, - method, - }); - text = await clientResponse.text(); - try { - parsedBody = JSON.parse(text); - } catch { - parsedBody = undefined; - } - }; - - await (turnState === undefined - ? runFetch() - : this.als.run(turnState, runFetch)); - - const recordedRequest = this.gateway.requests.at(-1); - - return { - clientResponse, - headers: clientResponse.headers, - json: (validate?: (data: unknown) => T): T => - validate === undefined ? (parsedBody as T) : validate(parsedBody), - ok: clientResponse.ok, - parsedBody, - recordedRequest, - request: recordedRequest, - status: clientResponse.status, - text, - }; - } -} - -export const MatrixRunner = GatewayTestRunner; diff --git a/test/opencode-store-contract.test.ts b/test/opencode-store-contract.test.ts new file mode 100644 index 00000000..31dcfc9a --- /dev/null +++ b/test/opencode-store-contract.test.ts @@ -0,0 +1,114 @@ +/** + * `opencode-store-contract.ts` · the inspection's whitelist and its Remote. + * + * The contract is the one place a field the host did not mean to send — a + * token, say — is dropped before it can reach the card, so the whitelist is + * asserted field by field, and the descriptor the client mounts is asserted + * against the parser it carries rather than against a copy of it. + * + * @module test/opencode-store-contract.test + */ + +import { describe, expect, it } from "vitest"; + +import { + type OpencodeStoreAccount, + parseStoreInspection, + STORE_NAMESPACE, + storeRemote, +} from "./support/src.ts"; + +describe("opencode-store-contract · parseStoreInspection", () => { + it("parseStoreInspection keeps the whitelisted fields and drops a token the host never meant to send", () => { + // Every field of the contract's own account, through `satisfies Required`: + // a field the type loses fails the excess-property check, and a field it + // gains fails the check until the fixture carries it. The input is built + // from the fixture, so a gained field then reaches the parser too, and the + // comparison fails until the whitelist keeps it. + const whitelisted = { + accountId: "acc_1", + email: "you@example.com", + expired: true, + expiresAt: "2026-01-01T00:00:00.000Z", + label: "Work", + orgName: "Org", + refreshable: false, + type: "oauth", + } satisfies Required; + const result = parseStoreInspection({ + account: { ...whitelisted, token: "secret" }, + found: true, + message: "m", + path: "opencode.db", + }); + expect(result).toEqual({ + account: whitelisted, + found: true, + message: "m", + path: "opencode.db", + }); + expect(JSON.stringify(result)).not.toContain("secret"); + }); + + it("parseStoreInspection drops account fields of the wrong type rather than refusing the account", () => { + expect( + parseStoreInspection({ + account: { email: 7, expired: "yes", refreshable: 1, type: "key" }, + found: true, + }) + ).toEqual({ account: { type: "key" }, found: true }); + }); + + it("parseStoreInspection answers undefined for anything that is not an inspection", () => { + for (const value of [ + undefined, + null, + "x", + 7, + {}, + { found: "yes" }, + { account: "oauth", found: true }, + { account: {}, found: true }, + { account: { type: "nope" }, found: true }, + ]) { + expect( + parseStoreInspection(value), + JSON.stringify(value) + ).toBeUndefined(); + } + expect( + parseStoreInspection({ found: false, message: 5, path: null }) + ).toEqual({ found: false }); + }); +}); + +describe("opencode-store-contract · storeRemote", () => { + it("storeRemote declares one direct inspect descriptor whose codec is parseStoreInspection", () => { + expect(STORE_NAMESPACE).toBe("opencodeStore"); + expect(storeRemote.package).toBe("dsh-opencode-patch"); + expect(storeRemote.descriptors).toHaveLength(1); + const [descriptor] = storeRemote.descriptors; + expect(descriptor).toMatchObject({ + id: "dsh-opencode-patch#opencodeStore/inspect", + invocation: { kind: "direct" }, + method: "inspect", + namespace: "opencodeStore", + parameters: [], + service: "opencodeStore", + }); + const result = descriptor?.result as { + create: () => { parse: (value: unknown) => unknown }; + mode: string; + schema: { parse: (value: unknown) => unknown }; + typeSymbol: string; + }; + expect(result.mode).toBe("strict"); + expect(result.typeSymbol).toBe( + "dsh-opencode-patch#OpencodeStoreInspection" + ); + for (const value of [{ found: true }, { found: "no" }]) { + expect(result.create().parse(value)).toEqual(parseStoreInspection(value)); + expect(result.schema.parse(value)).toEqual(parseStoreInspection(value)); + } + }); +}); diff --git a/test/opencode-store-service.test.ts b/test/opencode-store-service.test.ts index ea2e174d..6fb6f13b 100644 --- a/test/opencode-store-service.test.ts +++ b/test/opencode-store-service.test.ts @@ -8,16 +8,18 @@ * * The database is built here rather than found, so the suite does not need an * OpenCode installation — the path is injected for exactly that reason. + * + * @module test/opencode-store-service.test */ import { mkdtempSync, rmSync } from "node:fs"; import os from "node:os"; import nodePath from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { Context } from "@deepseek-ai/cordis"; import { afterEach, describe, expect, it } from "vitest"; import { OpencodeStoreService } from "./support/src.ts"; -import { createMockContext } from "./test-helpers.ts"; const temporary: string[] = []; @@ -66,7 +68,7 @@ const oauthDatabase = (expires: number): string => { /** The service over an injected path, which is what the seam allows. */ const serviceFor = (path: string): OpencodeStoreService => - new OpencodeStoreService(createMockContext(), { + new OpencodeStoreService(new Context(), { resolveStorePath: () => path, }); @@ -117,7 +119,7 @@ describe("opencode-store-service: the inspection seam", () => { // resolver has to be the default one. Asserted through the answer's shape // rather than the path's value — this machine may or may not have OpenCode, // and either answer is a correct one for "did it look somewhere". - const service = new OpencodeStoreService(createMockContext()); + const service = new OpencodeStoreService(new Context()); const result = await service.inspect(); expect(typeof result.found).toBe("boolean"); }); @@ -125,7 +127,7 @@ describe("opencode-store-service: the inspection seam", () => { it("ignores an injected resolver that is not a function", async () => { // `mountByProvide` passes its `options` through untouched, so a composition // that hands over something else must not turn into a crash at call time. - const service = new OpencodeStoreService(createMockContext(), { + const service = new OpencodeStoreService(new Context(), { resolveStorePath: "not a function", }); const result = await service.inspect(); diff --git a/test/plane-provider.test.ts b/test/plane-provider.test.ts index d9155831..7d60b0ab 100644 --- a/test/plane-provider.test.ts +++ b/test/plane-provider.test.ts @@ -5,7 +5,7 @@ * The adapter is built over the host's own `PiAiAdapter` and pi's own provider * pieces (`hostModulesForTests`), so a case here runs the class the plugin * registers. The real-host behaviour — which key a turn carries, what a - * missing one does on the wire — is pinned in `test/host-planes.test.ts`; this + * missing one does on the wire — is pinned in `test/host-planes-credentials.test.ts`; this * file covers the branches a host composition does not reach. * * @module test/plane-provider.test @@ -13,7 +13,11 @@ import { LlmError } from "@deepseek-ai/dsh-llm"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { fakeCredentials, hostModulesForTests } from "./support/host.ts"; +import { + fakeCredentials, + hostModulesForTests, + modelIds, +} from "./support/host.ts"; import { createOpencodeStore, type FixtureStore, @@ -33,10 +37,6 @@ import { relabelEfforts, } from "./support/src.ts"; -/** The ids of a listing, in order. */ -const modelIds = (models: readonly { id: string }[]): string[] => - models.map((model) => model.id); - afterEach(() => { vi.unstubAllEnvs(); }); diff --git a/test/purge-service.test.ts b/test/purge-service.test.ts index daa65693..db0d4b6b 100644 --- a/test/purge-service.test.ts +++ b/test/purge-service.test.ts @@ -6,6 +6,7 @@ * `status: "deprecated"` id and drops it from the active set — so these tests * seed the module through the parser rather than inventing a second list. */ +import { Context } from "@deepseek-ai/cordis"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -13,7 +14,6 @@ import { parseModelsDevCatalog, stripRetiredReferences, } from "./support/src.ts"; -import { createMockContext } from "./test-helpers.ts"; /** A registry reply with one retired id per plane and one live id beside it. */ const registry = () => ({ @@ -66,12 +66,14 @@ const editorWith = (models: { return { edit, entries: () => [entry], entry }; }; -/** A Cordis-shaped context: the Remote base needs `reflect`, the service `get`. */ -const ctxWith = (editor: unknown) => - ({ - ...(createMockContext() as object), - get: (name: string) => (name === "configEditor" ? editor : undefined), - }) as never; +/** A real cordis context, holding the config editor when there is one. */ +const ctxWith = (editor: unknown): Context => { + const ctx = new Context(); + if (editor !== undefined) { + ctx.provide("configEditor" as never, editor as never); + } + return ctx; +}; afterEach(() => { vi.unstubAllGlobals(); diff --git a/test/reload.test.ts b/test/reload.test.ts index d4b34225..87f32e62 100644 --- a/test/reload.test.ts +++ b/test/reload.test.ts @@ -40,6 +40,7 @@ import { piAiRestarts, s0Providers, type TierA, + until, waitForPlaneOwner, } from "./support/host.ts"; import { @@ -155,23 +156,6 @@ interface Site { /** A provider listing, for the `apply()` case's stand-in host. */ const LISTED = [{ id: "opencode", name: "opencode" }]; -/** Poll until `predicate` holds, or fail by name. */ -const until = async ( - predicate: () => boolean, - what: string, - deadlineMs = 10_000 -): Promise => { - const end = Date.now() + deadlineMs; - while (!predicate()) { - if (Date.now() > end) { - throw new Error(`never happened: ${what}`); - } - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - } -}; - /** pi-ai's descriptor as `settings.describe()` builds it without the planes. */ const describeMethods = (): Record => ({ describe: () => [ diff --git a/test/response-errors.test.ts b/test/response-errors.test.ts deleted file mode 100644 index 6d7ef177..00000000 --- a/test/response-errors.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Test: Gateway Error Codes & Failure Response Types. - * - * Validates: - * - 400 ModelProtocolUnsupported: model routed to incompatible endpoint - * - 400 MissingSessionID: missing session header on LLM calls - * - 400 invalid_request: malformed JSON or missing model field - * - 401 invalid_api_key: missing credential, invalid token, or placeholder key - * - 403 FreeTierError / model access disabled - * - 404 model_not_found: unknown model ID - * - 404 no_route: unknown root path - * - 404 unknown_endpoint: known family but invalid endpoint (e.g. double /v1) - * - 405 method_not_allowed: wrong HTTP method - * - 429 rate_limit_exceeded: rate limit simulation - * - 500 / 502 / 503: upstream gateway failures - * - * @module test/response-errors.test - */ - -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import type { GatewayErrorPayload } from "./mock/protocols/index.ts"; -import { GatewayTestRunner, ZEN_KEY } from "./mock/testing-kit.ts"; -import { clearCapturedApiKeys } from "./support/src.ts"; - -let runner: GatewayTestRunner; - -beforeAll(async () => { - runner = new GatewayTestRunner(); - await runner.start(); -}); - -afterAll(async () => { - await runner.close(); -}); - -beforeEach(() => { - runner.clearRequests(); - clearCapturedApiKeys(); - vi.stubEnv("OPENCODE_API_KEY", ZEN_KEY); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -describe("Gateway Error Codes & Failure Responses", () => { - it("returns 400 ModelProtocolUnsupported when model format mismatches endpoint", async () => { - const result = await runner.execute( - "opencode", - "/zen/v1/chat/completions", - { - body: { - messages: [{ content: "hi", role: "user" }], - model: "gpt-5.4", - stream: true, - }, - } - ); - - expect(result.status).toBe(400); - const body = result.json(); - expect(body.error.type).toBe("model_protocol_unsupported"); - expect(body.error.message).toContain("ModelProtocolUnsupported"); - expect(result.request?.reject).toBe("protocol"); - }); - - it("returns 400 MissingSessionID on direct calls lacking session headers", async () => { - const response = await fetch(`${runner.origin}/zen/v1/responses`, { - body: JSON.stringify({ input: [], model: "gpt-5.4", stream: true }), - headers: { - authorization: `Bearer ${ZEN_KEY}`, - "content-type": "application/json", - }, - method: "POST", - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as GatewayErrorPayload; - expect(body.error.type).toBe("missing_session_id"); - expect(runner.requests.at(-1)!.reject).toBe("session"); - }); - - it("returns 400 invalid_request when request body is not valid JSON", async () => { - const response = await fetch(`${runner.origin}/zen/v1/responses`, { - body: "{malformed-json-payload", - headers: { - authorization: `Bearer ${ZEN_KEY}`, - "content-type": "application/json", - "x-opencode-session-id": "ses_eb1e9e706ffeCDEFGHIJKLMNOP", - }, - method: "POST", - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as GatewayErrorPayload; - expect(body.error.type).toBe("invalid_request"); - expect(body.error.message).toContain("not JSON"); - }); - - it("returns 400 invalid_request when request body names no model", async () => { - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [] }, - }); - - expect(result.status).toBe(400); - const body = result.json(); - expect(body.error.type).toBe("invalid_request"); - expect(body.error.message).toContain("names no model"); - }); - - it("returns 401 invalid_api_key when API key is missing or a placeholder", async () => { - vi.stubEnv("OPENCODE_API_KEY", "unused"); - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "gpt-5.4", stream: true }, - }); - - expect(result.status).toBe(401); - const body = result.json(); - expect(body.error.type).toBe("invalid_api_key"); - expect(result.request?.reject).toBe("credential"); - }); - - it("returns 404 model_not_found when model ID does not exist in catalog", async () => { - const result = await runner.execute("opencode", "/zen/v1/responses", { - body: { input: [], model: "mythical-unknown-llm", stream: true }, - }); - - expect(result.status).toBe(404); - const body = result.json(); - expect(body.error.type).toBe("model_not_found"); - expect(result.request?.reject).toBe("model_unknown"); - }); - - it("returns 404 no_route when path does not match any known gateway root", async () => { - const response = await fetch(`${runner.origin}/completely/foreign/path`); - expect(response.status).toBe(404); - const body = (await response.json()) as GatewayErrorPayload; - expect(body.error.type).toBe("not_found"); - expect(runner.requests.at(-1)!.reject).toBe("no_route"); - }); - - it("returns 404 unknown_endpoint on known family with unrecognized endpoint", async () => { - const response = await fetch(`${runner.origin}/zen/v1/v1/messages`); - expect(response.status).toBe(404); - const body = (await response.json()) as GatewayErrorPayload; - expect(body.error.type).toBe("not_found"); - expect(runner.requests.at(-1)!.reject).toBe("unknown_endpoint"); - }); - - it("returns 405 method_not_allowed when using wrong HTTP verb", async () => { - const result = await runner.execute("opencode", "/zen/v1/responses", { - method: "GET", - }); - - expect(result.status).toBe(405); - const body = result.json(); - expect(body.error.type).toBe("method_not_allowed"); - expect(result.request?.reject).toBe("method"); - }); - - it("simulates rate limits (429) via gateway behavior override", async () => { - const rateLimitedRunner = new GatewayTestRunner(); - await rateLimitedRunner.start({ - behavior: (req) => - req.kind === "responses" - ? { - body: JSON.stringify({ - error: { - message: "Rate limit exceeded. Please wait 10s.", - type: "rate_limit_exceeded", - }, - }), - contentType: "application/json", - status: 429, - } - : undefined, - }); - - try { - const result = await rateLimitedRunner.execute( - "opencode", - "/zen/v1/responses", - { - body: { input: [], model: "gpt-5.4", stream: true }, - } - ); - - expect(result.status).toBe(429); - const body = result.json(); - expect(body.error.type).toBe("rate_limit_exceeded"); - } finally { - await rateLimitedRunner.close(); - } - }); - - it("simulates upstream gateway failures (502 Bad Gateway) cleanly", async () => { - const errorRunner = new GatewayTestRunner(); - await errorRunner.start({ - behavior: () => ({ - body: JSON.stringify({ - error: { message: "Bad Gateway", type: "bad_gateway" }, - }), - contentType: "application/json", - status: 502, - }), - }); - - try { - const result = await errorRunner.execute( - "opencode", - "/zen/v1/responses", - { - body: { input: [], model: "gpt-5.4", stream: true }, - } - ); - - expect(result.status).toBe(502); - const body = result.json(); - expect(body.error.type).toBe("bad_gateway"); - } finally { - await errorRunner.close(); - } - }); -}); diff --git a/test/route-claim-listener.test.ts b/test/route-claim-listener.test.ts new file mode 100644 index 00000000..abfb4722 --- /dev/null +++ b/test/route-claim-listener.test.ts @@ -0,0 +1,325 @@ +/** + * The route claim's `internal/config` listener, and a second pi-ai row, on a + * stand-in host (`support/route-claim-stand-in.ts`). + * + * @module test/route-claim-listener.test + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { hostModulesForTests } from "./support/host.ts"; +import { + flush, + piFiber, + restoreTestModules, + standIn, + useTestModules, + zen, +} from "./support/route-claim-stand-in.ts"; +import { installRouteClaim, resolveConfig } from "./support/src.ts"; + +afterEach(() => { + restoreTestModules(); + vi.restoreAllMocks(); +}); + +const config = resolveConfig(); + +describe("route-claim · the listener", () => { + it("passes through for another plugin, strips the planes for pi-ai, and validates only a changed plane of a candidate", async () => { + useTestModules(); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi] }); + const claim = installRouteClaim(host.ctx, config); + await claim.mounted; + await flush(); + const listener = host.listeners.get("internal/config"); + if (listener === undefined) { + throw new Error("no listener"); + } + const next = vi.fn(() => "next"); + expect(listener.call({ runtime: { name: "other" } }, {}, next)).toBe( + "next" + ); + // A commit holding a plane: the copy without it, and no throw. + const committed = listener.call(pi, pi._config, next) as { + providers: Record; + }; + expect(Object.keys(committed.providers)).toEqual(["deepseek"]); + // A commit holding none passes the raw value on. + const bare = { providers: { deepseek: {} } }; + pi._config = bare; + expect(listener.call(pi, bare, next)).toBe("next"); + // A candidate whose plane did not change is not validated: with a + // malformed model stored on the plane (pi-ai keeps it as a deferred + // error), validating it would refuse every save that touches only a + // sibling. + const malformed = { models: [{ id: "" }] }; + pi._config = { providers: { deepseek: {}, opencode: malformed } }; + const same = structuredClone(pi._config); + expect(listener.call(pi, same, next)).toEqual({ + providers: { deepseek: {} }, + }); + const siblingOnly = structuredClone(pi._config); + siblingOnly.providers.deepseek = { displayName: "D" }; + expect(listener.call(pi, siblingOnly, next)).toEqual({ + providers: { deepseek: { displayName: "D" } }, + }); + // The same plane changed is validated, and refused in pi-ai's words. + expect(() => + listener.call( + pi, + { + providers: { + deepseek: {}, + opencode: { models: [{ id: "" }, { id: "x" }] }, + }, + }, + next + ) + ).toThrow(/has a model with an empty id/u); + pi._config = { providers: { deepseek: {}, opencode: zen } }; + // A changed, malformed plane is refused in pi-ai's words. + expect(() => + listener.call( + pi, + { providers: { "opencode-go": { models: [{ id: "" }] } } }, + next + ) + ).toThrow(/has a model with an empty id/u); + // A host without `waterfall` still gets the copy. + pi.ctx = {}; + expect( + listener.call( + pi, + { providers: { opencode: { models: [{ id: "x" }] } } }, + next + ) + ).toEqual({ providers: {} }); + }); + + it("refuses to validate before the host's modules are read", async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = hostModulesForTests(); + useTestModules({ + libraries: async () => { + await gate; + return real.libraries(); + }, + piAi: real.piAi, + }); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi] }); + const claim = installRouteClaim(host.ctx, config); + expect(() => + host.listeners + .get("internal/config") + ?.call( + pi, + { providers: { opencode: { models: [{ id: "x" }] } } }, + () => "next" + ) + ).toThrow(/cannot be validated yet/u); + release?.(); + await claim.mounted; + }); +}); + +describe("route-claim · a second pi-ai row", () => { + it("strips and serves only one row's planes when both declare one; the other keeps and serves its own", async () => { + useTestModules(); + const first = piFiber(); + const second = piFiber({ + "opencode-go": { models: [{ id: "glm-5.1" }] }, + }); + second.uid = 2; + const host = standIn({ fibers: () => [first, second] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + const listener = host.listeners.get("internal/config"); + const next = vi.fn(() => "next"); + // The second row's config passes through untouched: pi-ai serves its plane. + expect(listener?.call(second, second._config, next)).toBe("next"); + // The first row's is stripped, and only its plane is registered. + expect(listener?.call(first, first._config, next)).toEqual({ + providers: { deepseek: {} }, + }); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); + expect(host.llm.registerAdapter.mock.calls[0]?.[0]).toEqual(["opencode"]); + }); + + it("serves the row that declares the planes, whatever the registry order: a re-enabled row cordis appended last", async () => { + useTestModules(); + const bare = piFiber({ deepseek: {} }); + const declaring = piFiber(); + declaring.uid = 2; + const host = standIn({ fibers: () => [bare, declaring] }); + const claim = installRouteClaim(host.ctx, config); + await claim.mounted; + await flush(); + const listener = host.listeners.get("internal/config"); + const next = vi.fn(() => "next"); + // The plane-less row ahead of it passes through: nothing to strip. + expect(listener?.call(bare, bare._config, next)).toBe("next"); + // The declaring row is the served one, though it comes second. + expect(listener?.call(declaring, declaring._config, next)).toEqual({ + providers: { deepseek: {} }, + }); + expect(host.llm.registerAdapter.mock.calls[0]?.[0]).toEqual(["opencode"]); + expect(claim.committedEntries()).toHaveProperty("opencode"); + }); + + it("the served row is sticky: a row that stops declaring hands over, and declaring again does not take the planes from the row now served", async () => { + useTestModules(); + const first = piFiber(); + first.restart = vi.fn(async () => {}); + const second = piFiber( + { deepseek: {}, "opencode-go": { models: [{ id: "glm-5.1" }] } }, + // Passed through at boot: it resolved and registered its plane itself. + { deepseek: {}, "opencode-go": {} } + ); + second.uid = 2; + second.entry = { options: { id: "llm-pi-ai:go" } }; + second.restart = vi.fn(async () => { + // It re-resolves through the listener: the plane is stripped. + second.config = { providers: { get: () => ({ deepseek: {} }) } }; + }); + const host = standIn({ fibers: () => [first, second] }); + const claim = installRouteClaim(host.ctx, config); + await claim.mounted; + await flush(); + const listener = host.listeners.get("internal/config"); + const next = vi.fn(() => "next"); + expect(host.llm.registerAdapter.mock.calls[0]?.[0]).toEqual(["opencode"]); + + // A Settings save removes the first row's plane: the second row, which + // declares one, becomes the served row and is restarted once to strip it. + first._config = { providers: { deepseek: {} } }; + expect(listener?.call(first, first._config, next)).toBe("next"); + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + expect(host.handle).toHaveBeenCalledTimes(1); + expect(second.restart).toHaveBeenCalledTimes(1); + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(2); + expect(host.llm.registerAdapter.mock.calls[1]?.[0]).toEqual([ + "opencode-go", + ]); + + // The first row declares `opencode` again. It does not take the served + // seat back: were it to, the second row would keep its stripped config + // and nobody would serve `opencode-go`. It passes through and serves its + // own plane, and is reported once. + first._config = { providers: { deepseek: {}, opencode: zen } }; + expect(listener?.call(first, first._config, next)).toBe("next"); + first.config = { + providers: { get: () => ({ deepseek: {}, opencode: {} }) }, + }; + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + expect(listener?.call(second, second._config, next)).toEqual({ + providers: { deepseek: {} }, + }); + expect(host.handle).toHaveBeenCalledTimes(1); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(2); + expect(Object.keys(claim.committedEntries())).toEqual(["opencode-go"]); + expect(claim.piAiNamespace()).toBe("llm-pi-ai:go"); + expect(second.restart).toHaveBeenCalledTimes(1); + expect(first.restart).not.toHaveBeenCalled(); + const reported = host.warn.mock.calls.filter(([message]) => + String(message).includes('"llm-pi-ai" serves opencode itself') + ); + expect(reported).toHaveLength(1); + }); + + it("a plane-less row ahead of the served row that gains a plane passes through: the served row keeps its plane", async () => { + useTestModules(); + const bare = piFiber({ deepseek: {} }); + const declaring = piFiber({ + deepseek: {}, + "opencode-go": { models: [{ id: "glm-5.1" }] }, + }); + declaring.uid = 2; + const host = standIn({ fibers: () => [bare, declaring] }); + const claim = installRouteClaim(host.ctx, config); + await claim.mounted; + await flush(); + const listener = host.listeners.get("internal/config"); + const next = vi.fn(() => "next"); + expect(host.llm.registerAdapter.mock.calls[0]?.[0]).toEqual([ + "opencode-go", + ]); + bare._config = { providers: { deepseek: {}, opencode: zen } }; + expect(listener?.call(bare, bare._config, next)).toBe("next"); + bare.config = { + providers: { get: () => ({ deepseek: {}, opencode: {} }) }, + }; + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + // Nothing was released or restarted, and `opencode-go` is still the plugin's. + expect(host.handle).not.toHaveBeenCalled(); + expect(declaring.restart).not.toHaveBeenCalled(); + expect(bare.restart).not.toHaveBeenCalled(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); + expect(Object.keys(claim.committedEntries())).toEqual(["opencode-go"]); + expect(listener?.call(declaring, declaring._config, next)).toEqual({ + providers: { deepseek: {} }, + }); + }); + + it("a disposed served row is handed over: its planes are released, the next row holding one is restarted once and then served", async () => { + useTestModules(); + const first = piFiber(); + const second = piFiber( + { deepseek: {}, "opencode-go": { models: [{ id: "glm-5.1" }] } }, + // It was passed through, so it resolved and registered its plane itself. + { deepseek: {}, "opencode-go": {} } + ); + second.uid = 2; + second.entry = { options: { id: "llm-pi-ai:second" } }; + second.restart = vi.fn(async () => { + // It re-resolves through the listener: the plane is stripped. + second.config = { providers: { get: () => ({ deepseek: {} }) } }; + }); + const host = standIn({ fibers: () => [first, second] }); + const claim = installRouteClaim(host.ctx, config); + await claim.mounted; + await flush(); + expect(host.llm.registerAdapter.mock.calls[0]?.[0]).toEqual(["opencode"]); + // The row serving a plane itself is reported, once. + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + const reported = host.warn.mock.calls.filter(([message]) => + String(message).includes('"llm-pi-ai:second" serves opencode-go itself') + ); + expect(reported).toHaveLength(1); + expect(second.restart).not.toHaveBeenCalled(); + + first.uid = null; + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + // The disposed row's plane is no longer served, nor grafted into Settings. + expect(host.handle).toHaveBeenCalledTimes(1); + expect(claim.committedEntries()).not.toHaveProperty("opencode"); + // The new served row held its plane, so it was restarted once to strip it. + expect(second.restart).toHaveBeenCalledTimes(1); + expect( + host.listeners + .get("internal/config") + ?.call(second, second._config, () => "next") + ).toEqual({ providers: { deepseek: {} } }); + // Its registry change brings the claim back: the plugin serves its plane. + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(2); + expect(host.llm.registerAdapter.mock.calls[1]?.[0]).toEqual([ + "opencode-go", + ]); + expect(Object.keys(claim.committedEntries())).toEqual(["opencode-go"]); + expect(claim.piAiNamespace()).toBe("llm-pi-ai:second"); + expect(second.restart).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/route-claim-reconcile.test.ts b/test/route-claim-reconcile.test.ts new file mode 100644 index 00000000..6c3931fd --- /dev/null +++ b/test/route-claim-reconcile.test.ts @@ -0,0 +1,171 @@ +/** + * The route claim's reconcile, and its read of the live catalog, on a + * stand-in host (`support/route-claim-stand-in.ts`). + * + * @module test/route-claim-reconcile.test + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { hostModulesForTests } from "./support/host.ts"; +import { + flush, + piFiber, + restoreTestModules, + servedModel, + standIn, + useTestModules, + zen, +} from "./support/route-claim-stand-in.ts"; +import { + installRouteClaim, + refreshCatalog, + resolveConfig, +} from "./support/src.ts"; + +afterEach(() => { + restoreTestModules(); + vi.restoreAllMocks(); +}); + +const config = resolveConfig(); + +describe("route-claim · the live catalog", () => { + it("a catalog refresh that lands after boot reaches the profiles: a declared id the shim does not describe changes protocol", async () => { + useTestModules(); + const id = "future-responses-model"; + const pi = piFiber({ deepseek: {}, opencode: { models: [{ id }] } }); + const host = standIn({ fibers: () => [pi] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + const adapter = host.llm.registerAdapter.mock.calls[0]?.[1]; + // Before the refresh the shim does not describe it: completions (D16). + expect(servedModel(adapter, "opencode", id)?.api).toBe( + "openai-completions" + ); + await refreshCatalog( + (async () => + Response.json({ + opencode: { + models: { + [id]: { name: "Future", provider: { npm: "@ai-sdk/openai" } }, + }, + }, + })) as unknown as typeof fetch, + true + ); + await flush(); + // No plane edit and no registry change: the refresh alone re-ran it. + expect(servedModel(adapter, "opencode", id)?.api).toBe("openai-responses"); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); + }); +}); + +describe("route-claim · reconcile", () => { + it("retries a DUPLICATE_ADAPTER refusal after pi-ai settles, and moves the routes on a rename", async () => { + useTestModules(); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi] }); + let refusals = 1; + host.llm.registerAdapter.mockImplementation(() => { + if (refusals > 0) { + refusals -= 1; + throw Object.assign(new Error("taken"), { code: "DUPLICATE_ADAPTER" }); + } + return host.handle; + }); + const claim = installRouteClaim(host.ctx, config); + await claim.mounted; + await flush(); + expect(claim.claiming()).toBe(true); + expect(host.info).toHaveBeenCalledWith( + expect.stringContaining("still held elsewhere") + ); + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + expect(pi.await).toHaveBeenCalled(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(2); + expect(host.llm.registerAdapter.mock.calls[1]?.[0]).toEqual(["opencode"]); + expect(claim.committedEntries()).toHaveProperty("opencode"); + expect(claim.piAiNamespace()).toBe("llm-pi-ai"); + // A rename is a registration fact: the live handle moves the routes. + pi._config = { + providers: { deepseek: {}, opencode: { ...zen, displayName: "Zen X" } }, + }; + host.listeners.get("internal/config")?.call(pi, pi._config, () => "next"); + await flush(); + expect(host.handle.replace).toHaveBeenCalledWith(["opencode"]); + // pi-ai going away withdraws them through the same handle. + pi.state = 3; + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + expect(host.handle.replace).toHaveBeenLastCalledWith([]); + expect(claim.committedEntries()).toEqual({}); + }); + + it("reports a registry refusal and a failed profile build, and waits while pi-ai still holds a plane", async () => { + const real = hostModulesForTests(); + const libraries = await real.libraries(); + if (libraries === undefined) { + throw new Error("no libraries"); + } + useTestModules({ + libraries: async () => ({ + ...libraries, + builtinModels: () => { + throw new Error("no catalog"); + }, + }), + piAi: real.piAi, + }); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + expect(host.warn).toHaveBeenCalledWith( + expect.stringContaining("the plane profiles did not build"), + expect.any(Error) + ); + restoreTestModules(); + useTestModules(); + const refused = standIn({ + fibers: () => [piFiber()], + llm: { + registerAdapter: vi.fn(() => { + throw new Error("no"); + }), + }, + }); + await installRouteClaim(refused.ctx, config).mounted; + await flush(); + expect(refused.warn).toHaveBeenCalledWith( + expect.stringContaining("did not register"), + expect.any(Error) + ); + // pi-ai resolved a plane before the listener existed: nothing registers + // until it restarts, and the mount restarts it once it has settled. + const holding = piFiber(undefined, { deepseek: {}, opencode: {} }); + const waiting = standIn({ fibers: () => [holding] }); + await installRouteClaim(waiting.ctx, config).mounted; + await flush(); + expect(holding.await).toHaveBeenCalled(); + expect(holding.restart).toHaveBeenCalledTimes(1); + expect(waiting.llm.registerAdapter).not.toHaveBeenCalled(); + }); + + it("follows pi-ai's loader entry being disposed, and ignores another entry's", async () => { + const piAi = vi.fn(hostModulesForTests().piAi); + useTestModules({ ...hostModulesForTests(), piAi }); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + const loads = piAi.mock.calls.length; + const partial = host.listeners.get("loader/partial-dispose"); + partial?.({ fiber: { runtime: { name: "other" } } }); + await flush(); + expect(piAi.mock.calls.length).toBe(loads); + partial?.({ fiber: pi }); + await flush(); + expect(piAi.mock.calls.length).toBe(loads + 1); + }); +}); diff --git a/test/route-claim-teardown.test.ts b/test/route-claim-teardown.test.ts new file mode 100644 index 00000000..024b1341 --- /dev/null +++ b/test/route-claim-teardown.test.ts @@ -0,0 +1,191 @@ +/** + * The route claim's teardown: the handback and its diagnostics, on a stand-in + * host (`support/route-claim-stand-in.ts`). + * + * @module test/route-claim-teardown.test + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + flush, + piFiber, + restoreTestModules, + standIn, + useTestModules, +} from "./support/route-claim-stand-in.ts"; +import { + catalogListenerCount, + installRouteClaim, + refreshCatalog, + resolveConfig, +} from "./support/src.ts"; + +afterEach(() => { + restoreTestModules(); + vi.restoreAllMocks(); +}); + +const config = resolveConfig(); + +describe("route-claim · teardown", () => { + it("on a reload releases the handle and restarts nothing", async () => { + useTestModules(); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi] }); + const claim = installRouteClaim(host.ctx, config); + await claim.mounted; + await flush(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); + expect(claim.claiming()).toBe(true); + const [teardown] = host.cleanups; + expect(teardown?.()).toBeUndefined(); + expect(host.handle).toHaveBeenCalledTimes(1); + expect(pi.restart).not.toHaveBeenCalled(); + expect(claim.claiming()).toBe(false); + }); + + it("unsubscribes from the catalog on a reload too, so a reloaded activation leaves nothing behind", async () => { + useTestModules(); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi], uid: 1 }); + const before = catalogListenerCount(); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + expect(catalogListenerCount()).toBe(before + 1); + // A reload: the plugin's own fiber keeps its uid. + host.cleanups[0]?.(); + expect(catalogListenerCount()).toBe(before); + await refreshCatalog( + (async () => + Response.json({ + opencode: { + models: { + "after-reload-model": { + name: "After", + provider: { npm: "@ai-sdk/openai" }, + }, + }, + }, + })) as unknown as typeof fetch, + true + ); + await flush(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); + expect(host.handle.replace).not.toHaveBeenCalled(); + }); + + it("on a real dispose restarts pi-ai to take the planes back, and says when it did not", async () => { + useTestModules(); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi], uid: 1 }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + host.fiber.uid = null as never; + await host.cleanups[0]?.(); + expect(pi.await).toHaveBeenCalled(); + expect(pi.restart).toHaveBeenCalledTimes(1); + expect(host.warn).toHaveBeenCalledWith( + expect.stringContaining("pi-ai did not take back opencode") + ); + }); + + it("restarts nothing while pi-ai's parent unloads, and reports a failed restart", async () => { + useTestModules(); + const shuttingDown = piFiber(); + shuttingDown.parent = { fiber: { state: 5 } }; + const host = standIn({ fibers: () => [shuttingDown] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + host.fiber.uid = null as never; + expect(host.cleanups[0]?.()).toBeUndefined(); + expect(shuttingDown.restart).not.toHaveBeenCalled(); + + const failing = piFiber(); + const second = standIn({ fibers: () => [failing] }); + await installRouteClaim(second.ctx, config).mounted; + await flush(); + failing.restart = vi.fn(async () => { + throw new Error("busy"); + }); + second.fiber.uid = null as never; + await second.cleanups[0]?.(); + expect(second.warn).toHaveBeenCalledWith( + expect.stringContaining("did not take the plane routes back"), + expect.any(Error) + ); + // A handle that throws on release is reported too. + const third = standIn({ fibers: () => [piFiber()] }); + third.handle.mockImplementation(() => { + throw new Error("gone"); + }); + await installRouteClaim(third.ctx, config).mounted; + await flush(); + third.cleanups[0]?.(); + expect(third.warn).toHaveBeenCalledWith( + expect.stringContaining("did not release"), + expect.any(Error) + ); + }); + + it("an llm scope that goes away drops the handle, so the next one registers afresh", async () => { + useTestModules(); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + // The llm scope's own effect is the last cleanup registered. + host.cleanups.at(-1)?.(); + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + // No llm, nothing to register on. + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); + // A new llm scope: a fresh registration on it, never a `replace` through + // the old scope's handle. + const nextHandle = Object.assign(vi.fn(), { replace: vi.fn() }); + const nextLlm = { + listProviders: () => [], + registerAdapter: vi.fn(() => nextHandle), + }; + host.reinject({ llm: nextLlm }); + await flush(); + expect(nextLlm.registerAdapter).toHaveBeenCalledTimes(1); + expect((nextLlm.registerAdapter.mock.calls[0] as unknown[])[0]).toEqual([ + "opencode", + ]); + expect(host.handle.replace).not.toHaveBeenCalled(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); + }); + + it("a real dispose while pi-ai is still LOADING restarts it once it settles without the planes; one that settled holding them is left alone", async () => { + useTestModules(); + const loading = piFiber(); + const host = standIn({ fibers: () => [loading] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + loading.state = 1; + loading.await = vi.fn(async () => { + loading.state = 2; + }); + host.fiber.uid = null as never; + await host.cleanups[0]?.(); + expect(loading.await).toHaveBeenCalled(); + expect(loading.restart).toHaveBeenCalledTimes(1); + + const settled = piFiber(); + const other = standIn({ fibers: () => [settled] }); + await installRouteClaim(other.ctx, config).mounted; + await flush(); + settled.state = 1; + settled.await = vi.fn(async () => { + // It resolved after the claim went inert: it holds its plane. + settled.config = { + providers: { get: () => ({ deepseek: {}, opencode: {} }) }, + }; + settled.state = 2; + }); + other.fiber.uid = null as never; + await other.cleanups[0]?.(); + expect(settled.await).toHaveBeenCalled(); + expect(settled.restart).not.toHaveBeenCalled(); + }); +}); diff --git a/test/route-claim.test.ts b/test/route-claim.test.ts index 90deac69..35654d02 100644 --- a/test/route-claim.test.ts +++ b/test/route-claim.test.ts @@ -2,173 +2,43 @@ * The route claim's clauses on a stand-in host (`src/host/route-claim.ts`). * * The behaviour on the real host — who owns the planes, how often pi-ai - * restarts, the lifecycle and the config flow — is pinned in - * `test/host-planes.test.ts` and the characterization. This file drives the + * restarts, the lifecycle and the config flow — is pinned in the + * `test/host-planes*.test.ts` files and the characterization. This file drives the * branches a real composition reaches only by accident: a host without the * cordis members the claim needs, a registry that refuses, a pi-ai that * fails, restarts or goes away, and the teardown's handback with its * diagnostics. The stand-in records what the claim registered, and the pi-ai * fiber is a plain object the claim reads through `ctx.registry`. * + * This file holds the claim standing down; its siblings hold the + * `internal/config` listener and a second pi-ai row (`route-claim-listener`), + * the live catalog and reconcile (`route-claim-reconcile`), and the teardown + * (`route-claim-teardown`). The stand-in is `support/route-claim-stand-in.ts`. + * * @module test/route-claim.test */ import { afterEach, describe, expect, it, vi } from "vitest"; import { hostModulesForTests } from "./support/host.ts"; import { - catalogListenerCount, + flush, + piFiber, + restoreTestModules, + standIn, + useTestModules, + zen, +} from "./support/route-claim-stand-in.ts"; +import { type CordisContext, - type HostModuleSource, installRouteClaim, - refreshCatalog, - replaceHostModuleSource, resolveConfig, } from "./support/src.ts"; -let restore: (() => void) | undefined; - afterEach(() => { - restore?.(); - restore = undefined; + restoreTestModules(); vi.restoreAllMocks(); }); -const flush = async (): Promise => { - for (let index = 0; index < 5; index += 1) { - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - } -}; - -type Listener = (this: unknown, ...args: unknown[]) => unknown; - -interface FakeFiber { - uid: number | null; - state: number; - runtime: { name: string }; - _config: { providers: Record }; - config: { providers: { get: () => Record } }; - ctx: { waterfall?: (...args: unknown[]) => unknown }; - entry?: unknown; - parent?: { fiber: { state: number } }; - await: () => Promise; - restart: () => Promise; -} - -const zen = { models: [{ id: "deepseek-v4-pro" }] }; - -/** A pi-ai fiber that holds `providers` raw and resolves `resolved`. */ -const piFiber = ( - providers?: Record, - resolved?: Record -): FakeFiber => ({ - _config: { providers: providers ?? { deepseek: {}, opencode: zen } }, - await: vi.fn(async () => {}), - config: { providers: { get: () => resolved ?? { deepseek: {} } } }, - ctx: { - waterfall: ( - _fiber: unknown, - _name: unknown, - _raw: unknown, - inner: unknown - ) => (inner as () => unknown)(), - }, - entry: { options: { id: "llm-pi-ai" } }, - parent: { fiber: { state: 2 } }, - restart: vi.fn(async () => {}), - runtime: { name: "llm-pi-ai" }, - state: 2, - uid: 1, -}); - -/** A stand-in plugin context: records listeners, effects and registrations. */ -const standIn = ( - options: { - fibers?: () => FakeFiber[]; - llm?: Record; - uid?: number | null; - } = {} -) => { - const listeners = new Map(); - const cleanups: (() => unknown)[] = []; - const warn = vi.fn(); - const info = vi.fn(); - const handle = Object.assign(vi.fn(), { replace: vi.fn() }); - const llm = { - listProviders: () => [{ id: "deepseek" }], - registerAdapter: vi.fn( - (_routes: readonly string[], _adapter: unknown) => handle - ), - ...options.llm, - }; - const fiber = { uid: options.uid === undefined ? 1 : options.uid }; - let injected: ((scope: unknown) => void) | undefined; - const effect = (fn: () => unknown) => { - const cleanup = fn(); - if (typeof cleanup === "function") { - cleanups.push(cleanup as () => unknown); - } - }; - const ctx = { - effect, - fiber, - get: (name: string) => (name === "llm" ? llm : undefined), - inject: (deps: string[], callback: (scope: unknown) => void) => { - injected = callback; - callback(deps.includes("llm") ? { effect, llm } : {}); - }, - logger: { info, warn }, - on: (event: string, listener: Listener) => { - listeners.set(event, listener); - }, - registry: { - values: () => [ - { fibers: options.fibers?.() ?? [], name: "llm-pi-ai" }, - { fibers: [], name: "other" }, - ], - }, - } as unknown as CordisContext; - /** Run the `llm` inject callback again, as cordis does for a new scope. */ - const reinject = (scope: Record): void => { - injected?.({ effect, ...scope }); - }; - return { - cleanups, - ctx, - fiber, - handle, - info, - listeners, - llm, - reinject, - warn, - }; -}; - -/** The model a registered adapter's profile serves for a route. */ -const servedModel = ( - adapter: unknown, - route: string, - id: string -): Record | undefined => { - const { config: options } = adapter as { - config: { - profiles: () => ReadonlyMap; - }; - }; - const provider = options.profiles().get(route)?.piProvider as - | { getModels: () => Record[] } - | undefined; - return provider?.getModels().find((model) => model.id === id); -}; - -const useTestModules = ( - source: HostModuleSource = hostModulesForTests() -): void => { - restore = replaceHostModuleSource(source); -}; - const config = resolveConfig(); describe("route-claim · standing down", () => { @@ -310,607 +180,3 @@ describe("route-claim · standing down", () => { ); }); }); - -describe("route-claim · the listener", () => { - it("passes through for another plugin, strips the planes for pi-ai, and validates only a changed plane of a candidate", async () => { - useTestModules(); - const pi = piFiber(); - const host = standIn({ fibers: () => [pi] }); - const claim = installRouteClaim(host.ctx, config); - await claim.mounted; - await flush(); - const listener = host.listeners.get("internal/config"); - if (listener === undefined) { - throw new Error("no listener"); - } - const next = vi.fn(() => "next"); - expect(listener.call({ runtime: { name: "other" } }, {}, next)).toBe( - "next" - ); - // A commit holding a plane: the copy without it, and no throw. - const committed = listener.call(pi, pi._config, next) as { - providers: Record; - }; - expect(Object.keys(committed.providers)).toEqual(["deepseek"]); - // A commit holding none passes the raw value on. - const bare = { providers: { deepseek: {} } }; - pi._config = bare; - expect(listener.call(pi, bare, next)).toBe("next"); - // A candidate whose plane did not change is not validated: with a - // malformed model stored on the plane (pi-ai keeps it as a deferred - // error), validating it would refuse every save that touches only a - // sibling. - const malformed = { models: [{ id: "" }] }; - pi._config = { providers: { deepseek: {}, opencode: malformed } }; - const same = structuredClone(pi._config); - expect(listener.call(pi, same, next)).toEqual({ - providers: { deepseek: {} }, - }); - const siblingOnly = structuredClone(pi._config); - siblingOnly.providers.deepseek = { displayName: "D" }; - expect(listener.call(pi, siblingOnly, next)).toEqual({ - providers: { deepseek: { displayName: "D" } }, - }); - // The same plane changed is validated, and refused in pi-ai's words. - expect(() => - listener.call( - pi, - { - providers: { - deepseek: {}, - opencode: { models: [{ id: "" }, { id: "x" }] }, - }, - }, - next - ) - ).toThrow(/has a model with an empty id/u); - pi._config = { providers: { deepseek: {}, opencode: zen } }; - // A changed, malformed plane is refused in pi-ai's words. - expect(() => - listener.call( - pi, - { providers: { "opencode-go": { models: [{ id: "" }] } } }, - next - ) - ).toThrow(/has a model with an empty id/u); - // A host without `waterfall` still gets the copy. - pi.ctx = {}; - expect( - listener.call( - pi, - { providers: { opencode: { models: [{ id: "x" }] } } }, - next - ) - ).toEqual({ providers: {} }); - }); - - it("refuses to validate before the host's modules are read", async () => { - let release: (() => void) | undefined; - const gate = new Promise((resolve) => { - release = resolve; - }); - const real = hostModulesForTests(); - useTestModules({ - libraries: async () => { - await gate; - return real.libraries(); - }, - piAi: real.piAi, - }); - const pi = piFiber(); - const host = standIn({ fibers: () => [pi] }); - const claim = installRouteClaim(host.ctx, config); - expect(() => - host.listeners - .get("internal/config") - ?.call( - pi, - { providers: { opencode: { models: [{ id: "x" }] } } }, - () => "next" - ) - ).toThrow(/cannot be validated yet/u); - release?.(); - await claim.mounted; - }); -}); - -describe("route-claim · a second pi-ai row", () => { - it("strips and serves only one row's planes when both declare one; the other keeps and serves its own", async () => { - useTestModules(); - const first = piFiber(); - const second = piFiber({ - "opencode-go": { models: [{ id: "glm-5.1" }] }, - }); - second.uid = 2; - const host = standIn({ fibers: () => [first, second] }); - await installRouteClaim(host.ctx, config).mounted; - await flush(); - const listener = host.listeners.get("internal/config"); - const next = vi.fn(() => "next"); - // The second row's config passes through untouched: pi-ai serves its plane. - expect(listener?.call(second, second._config, next)).toBe("next"); - // The first row's is stripped, and only its plane is registered. - expect(listener?.call(first, first._config, next)).toEqual({ - providers: { deepseek: {} }, - }); - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); - expect(host.llm.registerAdapter.mock.calls[0]?.[0]).toEqual(["opencode"]); - }); - - it("serves the row that declares the planes, whatever the registry order: a re-enabled row cordis appended last", async () => { - useTestModules(); - const bare = piFiber({ deepseek: {} }); - const declaring = piFiber(); - declaring.uid = 2; - const host = standIn({ fibers: () => [bare, declaring] }); - const claim = installRouteClaim(host.ctx, config); - await claim.mounted; - await flush(); - const listener = host.listeners.get("internal/config"); - const next = vi.fn(() => "next"); - // The plane-less row ahead of it passes through: nothing to strip. - expect(listener?.call(bare, bare._config, next)).toBe("next"); - // The declaring row is the served one, though it comes second. - expect(listener?.call(declaring, declaring._config, next)).toEqual({ - providers: { deepseek: {} }, - }); - expect(host.llm.registerAdapter.mock.calls[0]?.[0]).toEqual(["opencode"]); - expect(claim.committedEntries()).toHaveProperty("opencode"); - }); - - it("the served row is sticky: a row that stops declaring hands over, and declaring again does not take the planes from the row now served", async () => { - useTestModules(); - const first = piFiber(); - first.restart = vi.fn(async () => {}); - const second = piFiber( - { deepseek: {}, "opencode-go": { models: [{ id: "glm-5.1" }] } }, - // Passed through at boot: it resolved and registered its plane itself. - { deepseek: {}, "opencode-go": {} } - ); - second.uid = 2; - second.entry = { options: { id: "llm-pi-ai:go" } }; - second.restart = vi.fn(async () => { - // It re-resolves through the listener: the plane is stripped. - second.config = { providers: { get: () => ({ deepseek: {} }) } }; - }); - const host = standIn({ fibers: () => [first, second] }); - const claim = installRouteClaim(host.ctx, config); - await claim.mounted; - await flush(); - const listener = host.listeners.get("internal/config"); - const next = vi.fn(() => "next"); - expect(host.llm.registerAdapter.mock.calls[0]?.[0]).toEqual(["opencode"]); - - // A Settings save removes the first row's plane: the second row, which - // declares one, becomes the served row and is restarted once to strip it. - first._config = { providers: { deepseek: {} } }; - expect(listener?.call(first, first._config, next)).toBe("next"); - host.listeners.get("llm/adapters-updated")?.(); - await flush(); - expect(host.handle).toHaveBeenCalledTimes(1); - expect(second.restart).toHaveBeenCalledTimes(1); - host.listeners.get("llm/adapters-updated")?.(); - await flush(); - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(2); - expect(host.llm.registerAdapter.mock.calls[1]?.[0]).toEqual([ - "opencode-go", - ]); - - // The first row declares `opencode` again. It does not take the served - // seat back: were it to, the second row would keep its stripped config - // and nobody would serve `opencode-go`. It passes through and serves its - // own plane, and is reported once. - first._config = { providers: { deepseek: {}, opencode: zen } }; - expect(listener?.call(first, first._config, next)).toBe("next"); - first.config = { - providers: { get: () => ({ deepseek: {}, opencode: {} }) }, - }; - host.listeners.get("llm/adapters-updated")?.(); - await flush(); - expect(listener?.call(second, second._config, next)).toEqual({ - providers: { deepseek: {} }, - }); - expect(host.handle).toHaveBeenCalledTimes(1); - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(2); - expect(Object.keys(claim.committedEntries())).toEqual(["opencode-go"]); - expect(claim.piAiNamespace()).toBe("llm-pi-ai:go"); - expect(second.restart).toHaveBeenCalledTimes(1); - expect(first.restart).not.toHaveBeenCalled(); - const reported = host.warn.mock.calls.filter(([message]) => - String(message).includes('"llm-pi-ai" serves opencode itself') - ); - expect(reported).toHaveLength(1); - }); - - it("a plane-less row ahead of the served row that gains a plane passes through: the served row keeps its plane", async () => { - useTestModules(); - const bare = piFiber({ deepseek: {} }); - const declaring = piFiber({ - deepseek: {}, - "opencode-go": { models: [{ id: "glm-5.1" }] }, - }); - declaring.uid = 2; - const host = standIn({ fibers: () => [bare, declaring] }); - const claim = installRouteClaim(host.ctx, config); - await claim.mounted; - await flush(); - const listener = host.listeners.get("internal/config"); - const next = vi.fn(() => "next"); - expect(host.llm.registerAdapter.mock.calls[0]?.[0]).toEqual([ - "opencode-go", - ]); - bare._config = { providers: { deepseek: {}, opencode: zen } }; - expect(listener?.call(bare, bare._config, next)).toBe("next"); - bare.config = { - providers: { get: () => ({ deepseek: {}, opencode: {} }) }, - }; - host.listeners.get("llm/adapters-updated")?.(); - await flush(); - // Nothing was released or restarted, and `opencode-go` is still the plugin's. - expect(host.handle).not.toHaveBeenCalled(); - expect(declaring.restart).not.toHaveBeenCalled(); - expect(bare.restart).not.toHaveBeenCalled(); - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); - expect(Object.keys(claim.committedEntries())).toEqual(["opencode-go"]); - expect(listener?.call(declaring, declaring._config, next)).toEqual({ - providers: { deepseek: {} }, - }); - }); - - it("a disposed served row is handed over: its planes are released, the next row holding one is restarted once and then served", async () => { - useTestModules(); - const first = piFiber(); - const second = piFiber( - { deepseek: {}, "opencode-go": { models: [{ id: "glm-5.1" }] } }, - // It was passed through, so it resolved and registered its plane itself. - { deepseek: {}, "opencode-go": {} } - ); - second.uid = 2; - second.entry = { options: { id: "llm-pi-ai:second" } }; - second.restart = vi.fn(async () => { - // It re-resolves through the listener: the plane is stripped. - second.config = { providers: { get: () => ({ deepseek: {} }) } }; - }); - const host = standIn({ fibers: () => [first, second] }); - const claim = installRouteClaim(host.ctx, config); - await claim.mounted; - await flush(); - expect(host.llm.registerAdapter.mock.calls[0]?.[0]).toEqual(["opencode"]); - // The row serving a plane itself is reported, once. - host.listeners.get("llm/adapters-updated")?.(); - await flush(); - const reported = host.warn.mock.calls.filter(([message]) => - String(message).includes('"llm-pi-ai:second" serves opencode-go itself') - ); - expect(reported).toHaveLength(1); - expect(second.restart).not.toHaveBeenCalled(); - - first.uid = null; - host.listeners.get("llm/adapters-updated")?.(); - await flush(); - // The disposed row's plane is no longer served, nor grafted into Settings. - expect(host.handle).toHaveBeenCalledTimes(1); - expect(claim.committedEntries()).not.toHaveProperty("opencode"); - // The new served row held its plane, so it was restarted once to strip it. - expect(second.restart).toHaveBeenCalledTimes(1); - expect( - host.listeners - .get("internal/config") - ?.call(second, second._config, () => "next") - ).toEqual({ providers: { deepseek: {} } }); - // Its registry change brings the claim back: the plugin serves its plane. - host.listeners.get("llm/adapters-updated")?.(); - await flush(); - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(2); - expect(host.llm.registerAdapter.mock.calls[1]?.[0]).toEqual([ - "opencode-go", - ]); - expect(Object.keys(claim.committedEntries())).toEqual(["opencode-go"]); - expect(claim.piAiNamespace()).toBe("llm-pi-ai:second"); - expect(second.restart).toHaveBeenCalledTimes(1); - }); -}); - -describe("route-claim · the live catalog", () => { - it("a catalog refresh that lands after boot reaches the profiles: a declared id the shim does not describe changes protocol", async () => { - useTestModules(); - const id = "future-responses-model"; - const pi = piFiber({ deepseek: {}, opencode: { models: [{ id }] } }); - const host = standIn({ fibers: () => [pi] }); - await installRouteClaim(host.ctx, config).mounted; - await flush(); - const adapter = host.llm.registerAdapter.mock.calls[0]?.[1]; - // Before the refresh the shim does not describe it: completions (D16). - expect(servedModel(adapter, "opencode", id)?.api).toBe( - "openai-completions" - ); - await refreshCatalog( - (async () => - Response.json({ - opencode: { - models: { - [id]: { name: "Future", provider: { npm: "@ai-sdk/openai" } }, - }, - }, - })) as unknown as typeof fetch, - true - ); - await flush(); - // No plane edit and no registry change: the refresh alone re-ran it. - expect(servedModel(adapter, "opencode", id)?.api).toBe("openai-responses"); - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); - }); -}); - -describe("route-claim · reconcile", () => { - it("retries a DUPLICATE_ADAPTER refusal after pi-ai settles, and moves the routes on a rename", async () => { - useTestModules(); - const pi = piFiber(); - const host = standIn({ fibers: () => [pi] }); - let refusals = 1; - host.llm.registerAdapter.mockImplementation(() => { - if (refusals > 0) { - refusals -= 1; - throw Object.assign(new Error("taken"), { code: "DUPLICATE_ADAPTER" }); - } - return host.handle; - }); - const claim = installRouteClaim(host.ctx, config); - await claim.mounted; - await flush(); - expect(claim.claiming()).toBe(true); - expect(host.info).toHaveBeenCalledWith( - expect.stringContaining("still held elsewhere") - ); - host.listeners.get("llm/adapters-updated")?.(); - await flush(); - expect(pi.await).toHaveBeenCalled(); - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(2); - expect(host.llm.registerAdapter.mock.calls[1]?.[0]).toEqual(["opencode"]); - expect(claim.committedEntries()).toHaveProperty("opencode"); - expect(claim.piAiNamespace()).toBe("llm-pi-ai"); - // A rename is a registration fact: the live handle moves the routes. - pi._config = { - providers: { deepseek: {}, opencode: { ...zen, displayName: "Zen X" } }, - }; - host.listeners.get("internal/config")?.call(pi, pi._config, () => "next"); - await flush(); - expect(host.handle.replace).toHaveBeenCalledWith(["opencode"]); - // pi-ai going away withdraws them through the same handle. - pi.state = 3; - host.listeners.get("llm/adapters-updated")?.(); - await flush(); - expect(host.handle.replace).toHaveBeenLastCalledWith([]); - expect(claim.committedEntries()).toEqual({}); - }); - - it("reports a registry refusal and a failed profile build, and waits while pi-ai still holds a plane", async () => { - const real = hostModulesForTests(); - const libraries = await real.libraries(); - if (libraries === undefined) { - throw new Error("no libraries"); - } - useTestModules({ - libraries: async () => ({ - ...libraries, - builtinModels: () => { - throw new Error("no catalog"); - }, - }), - piAi: real.piAi, - }); - const pi = piFiber(); - const host = standIn({ fibers: () => [pi] }); - await installRouteClaim(host.ctx, config).mounted; - await flush(); - expect(host.warn).toHaveBeenCalledWith( - expect.stringContaining("the plane profiles did not build"), - expect.any(Error) - ); - restore?.(); - useTestModules(); - const refused = standIn({ - fibers: () => [piFiber()], - llm: { - registerAdapter: vi.fn(() => { - throw new Error("no"); - }), - }, - }); - await installRouteClaim(refused.ctx, config).mounted; - await flush(); - expect(refused.warn).toHaveBeenCalledWith( - expect.stringContaining("did not register"), - expect.any(Error) - ); - // pi-ai resolved a plane before the listener existed: nothing registers - // until it restarts, and the mount restarts it once it has settled. - const holding = piFiber(undefined, { deepseek: {}, opencode: {} }); - const waiting = standIn({ fibers: () => [holding] }); - await installRouteClaim(waiting.ctx, config).mounted; - await flush(); - expect(holding.await).toHaveBeenCalled(); - expect(holding.restart).toHaveBeenCalledTimes(1); - expect(waiting.llm.registerAdapter).not.toHaveBeenCalled(); - }); - - it("follows pi-ai's loader entry being disposed, and ignores another entry's", async () => { - const piAi = vi.fn(hostModulesForTests().piAi); - useTestModules({ ...hostModulesForTests(), piAi }); - const pi = piFiber(); - const host = standIn({ fibers: () => [pi] }); - await installRouteClaim(host.ctx, config).mounted; - await flush(); - const loads = piAi.mock.calls.length; - const partial = host.listeners.get("loader/partial-dispose"); - partial?.({ fiber: { runtime: { name: "other" } } }); - await flush(); - expect(piAi.mock.calls.length).toBe(loads); - partial?.({ fiber: pi }); - await flush(); - expect(piAi.mock.calls.length).toBe(loads + 1); - }); -}); - -describe("route-claim · teardown", () => { - it("on a reload releases the handle and restarts nothing", async () => { - useTestModules(); - const pi = piFiber(); - const host = standIn({ fibers: () => [pi] }); - const claim = installRouteClaim(host.ctx, config); - await claim.mounted; - await flush(); - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); - expect(claim.claiming()).toBe(true); - const [teardown] = host.cleanups; - expect(teardown?.()).toBeUndefined(); - expect(host.handle).toHaveBeenCalledTimes(1); - expect(pi.restart).not.toHaveBeenCalled(); - expect(claim.claiming()).toBe(false); - }); - - it("unsubscribes from the catalog on a reload too, so a reloaded activation leaves nothing behind", async () => { - useTestModules(); - const pi = piFiber(); - const host = standIn({ fibers: () => [pi], uid: 1 }); - const before = catalogListenerCount(); - await installRouteClaim(host.ctx, config).mounted; - await flush(); - expect(catalogListenerCount()).toBe(before + 1); - // A reload: the plugin's own fiber keeps its uid. - host.cleanups[0]?.(); - expect(catalogListenerCount()).toBe(before); - await refreshCatalog( - (async () => - Response.json({ - opencode: { - models: { - "after-reload-model": { - name: "After", - provider: { npm: "@ai-sdk/openai" }, - }, - }, - }, - })) as unknown as typeof fetch, - true - ); - await flush(); - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); - expect(host.handle.replace).not.toHaveBeenCalled(); - }); - - it("on a real dispose restarts pi-ai to take the planes back, and says when it did not", async () => { - useTestModules(); - const pi = piFiber(); - const host = standIn({ fibers: () => [pi], uid: 1 }); - await installRouteClaim(host.ctx, config).mounted; - await flush(); - host.fiber.uid = null as never; - await host.cleanups[0]?.(); - expect(pi.await).toHaveBeenCalled(); - expect(pi.restart).toHaveBeenCalledTimes(1); - expect(host.warn).toHaveBeenCalledWith( - expect.stringContaining("pi-ai did not take back opencode") - ); - }); - - it("restarts nothing while pi-ai's parent unloads, and reports a failed restart", async () => { - useTestModules(); - const shuttingDown = piFiber(); - shuttingDown.parent = { fiber: { state: 5 } }; - const host = standIn({ fibers: () => [shuttingDown] }); - await installRouteClaim(host.ctx, config).mounted; - await flush(); - host.fiber.uid = null as never; - expect(host.cleanups[0]?.()).toBeUndefined(); - expect(shuttingDown.restart).not.toHaveBeenCalled(); - - const failing = piFiber(); - const second = standIn({ fibers: () => [failing] }); - await installRouteClaim(second.ctx, config).mounted; - await flush(); - failing.restart = vi.fn(async () => { - throw new Error("busy"); - }); - second.fiber.uid = null as never; - await second.cleanups[0]?.(); - expect(second.warn).toHaveBeenCalledWith( - expect.stringContaining("did not take the plane routes back"), - expect.any(Error) - ); - // A handle that throws on release is reported too. - const third = standIn({ fibers: () => [piFiber()] }); - third.handle.mockImplementation(() => { - throw new Error("gone"); - }); - await installRouteClaim(third.ctx, config).mounted; - await flush(); - third.cleanups[0]?.(); - expect(third.warn).toHaveBeenCalledWith( - expect.stringContaining("did not release"), - expect.any(Error) - ); - }); - - it("an llm scope that goes away drops the handle, so the next one registers afresh", async () => { - useTestModules(); - const pi = piFiber(); - const host = standIn({ fibers: () => [pi] }); - await installRouteClaim(host.ctx, config).mounted; - await flush(); - // The llm scope's own effect is the last cleanup registered. - host.cleanups.at(-1)?.(); - host.listeners.get("llm/adapters-updated")?.(); - await flush(); - // No llm, nothing to register on. - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); - // A new llm scope: a fresh registration on it, never a `replace` through - // the old scope's handle. - const nextHandle = Object.assign(vi.fn(), { replace: vi.fn() }); - const nextLlm = { - listProviders: () => [], - registerAdapter: vi.fn(() => nextHandle), - }; - host.reinject({ llm: nextLlm }); - await flush(); - expect(nextLlm.registerAdapter).toHaveBeenCalledTimes(1); - expect((nextLlm.registerAdapter.mock.calls[0] as unknown[])[0]).toEqual([ - "opencode", - ]); - expect(host.handle.replace).not.toHaveBeenCalled(); - expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); - }); - - it("a real dispose while pi-ai is still LOADING restarts it once it settles without the planes; one that settled holding them is left alone", async () => { - useTestModules(); - const loading = piFiber(); - const host = standIn({ fibers: () => [loading] }); - await installRouteClaim(host.ctx, config).mounted; - await flush(); - loading.state = 1; - loading.await = vi.fn(async () => { - loading.state = 2; - }); - host.fiber.uid = null as never; - await host.cleanups[0]?.(); - expect(loading.await).toHaveBeenCalled(); - expect(loading.restart).toHaveBeenCalledTimes(1); - - const settled = piFiber(); - const other = standIn({ fibers: () => [settled] }); - await installRouteClaim(other.ctx, config).mounted; - await flush(); - settled.state = 1; - settled.await = vi.fn(async () => { - // It resolved after the claim went inert: it holds its plane. - settled.config = { - providers: { get: () => ({ deepseek: {}, opencode: {} }) }, - }; - settled.state = 2; - }); - other.fiber.uid = null as never; - await other.cleanups[0]?.(); - expect(settled.await).toHaveBeenCalled(); - expect(settled.restart).not.toHaveBeenCalled(); - }); -}); diff --git a/test/session-id.test.ts b/test/session-id.test.ts new file mode 100644 index 00000000..9dd036ae --- /dev/null +++ b/test/session-id.test.ts @@ -0,0 +1,212 @@ +/** + * `utils/session-id.ts` · the pure half of a session id. + * + * Every rule here takes its digest as an argument, so each case hands it a + * DETERMINISTIC one and checks the construction against the vendor's own + * arithmetic, restated in the test rather than read from the module: a test + * that asks the implementation what it does can only ever agree with it. + * + * The effect half — hashing a DSH session id and reading the environment — is + * `test/session.test.ts`. + * + * @module test/session-id.test + */ + +import { describe, expect, it } from "vitest"; + +import { + deriveSessionId, + isOpenCodeMessageId, + isOpenCodeSessionId, + pinnedSessionId, + type SessionDigest, + sessionIdForRequest, +} from "./support/src.ts"; + +/** The vendor's alphabet (`randomBase62` in `packages/opencode/src/id/id.ts`). */ +const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +const MASK = 2n ** 48n; + +const hexOf = (bytes: Uint8Array): string => + [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); + +/** A digest built from fixed bytes, so the expected suffix can be computed. */ +const fixedDigest = (bytes: Uint8Array): SessionDigest => ({ + bytes, + hex: hexOf(bytes), +}); + +/** + * A DETERMINISTIC digest: the point of injecting it is that the rule can be + * exercised without arranging a crypto module or depending on a real hash. + */ +const digest = (value: string): SessionDigest => { + const bytes = new Uint8Array(32); + for (let i = 0; i < value.length; i += 1) { + bytes[i % 32] = (bytes[i % 32] ?? 0) + (value.codePointAt(i) ?? 0); + } + return fixedDigest(bytes); +}; + +/** The vendor's descending time part for an anchor, counter 1. */ +const descendingTime = (anchor: bigint): bigint => + MASK - 1n - ((anchor * 4096n + 1n) % MASK); + +const timePart = (id: string): bigint => BigInt(`0x${id.slice(4, 16)}`); + +describe("session-id · deriveSessionId", () => { + const BYTES = Uint8Array.from({ length: 32 }, (_, i) => (i * 7) % 256); + + it("inverts createdAt*4096+1 in 48 bits and takes the suffix from bytes 6..19 in base62", () => { + const createdAt = 1_700_000_000_000; + const id = deriveSessionId(fixedDigest(BYTES), createdAt); + expect(timePart(id)).toBe(descendingTime(BigInt(createdAt))); + // Counter 1, inverted: the low twelve bits sit at 0xFFE, the gateway's band. + expect(Number.parseInt(id.slice(4, 16), 16) % 0x1000).toBe(0xffe); + expect(id.slice(16)).toBe( + Array.from(BYTES.slice(6, 20), (b) => BASE62[b % 62]).join("") + ); + expect(id).toHaveLength(30); + expect(isOpenCodeSessionId(id)).toBe(true); + }); + + it("anchors on the digest's hex[4..16] when createdAt is absent, negative, fractional or unsafe", () => { + const d = digest("anchor"); + const hashed = deriveSessionId(d); + for (const createdAt of [undefined, -1, 1.5, 2 ** 53]) { + expect(deriveSessionId(d, createdAt), String(createdAt)).toBe(hashed); + } + expect(timePart(hashed)).toBe( + descendingTime(BigInt(`0x${d.hex.slice(4, 16)}`)) + ); + // Zero is a time, not an absence. + expect(deriveSessionId(d, 0)).not.toBe(hashed); + }); + + it("wraps at 48 bits the way the vendor's six-byte buffer does", () => { + // `2**36 * 4096` is exactly 2**48: the stored value wraps to the counter + // alone, so the inversion reads all ones but the lowest bit. + const id = deriveSessionId(digest("wrap"), 2 ** 36); + expect(timePart(id)).toBe(MASK - 2n); + expect(id.slice(4, 16)).toBe("fffffffffffe"); + }); + + it("keeps the suffix of a digest when only the creation time changes", () => { + const d = digest("same-digest"); + const early = deriveSessionId(d, 1_600_000_000_000); + const late = deriveSessionId(d, 1_700_000_000_000); + expect(early.slice(16)).toBe(late.slice(16)); + expect(early.slice(4, 16)).not.toBe(late.slice(4, 16)); + }); + + it("with a digest shorter than 20 bytes still yields the 12-hex time part and does not throw", () => { + const short: SessionDigest = { + bytes: new Uint8Array(8), + hex: "00".repeat(8), + }; + expect(() => deriveSessionId(short)).not.toThrow(); + const id = deriveSessionId(short); + expect(id.startsWith("ses_")).toBe(true); + expect(id.slice(4, 16)).toMatch(/^[0-9a-f]{12}$/u); + // Bytes 6 and 7 are all the suffix there is. + expect(id).toHaveLength(4 + 12 + 2); + }); +}); + +describe("session-id · pinnedSessionId", () => { + it("returns a pinned id the gateway accepts and derives the same default for anything else", () => { + const valid = deriveSessionId(digest("any")); + expect(pinnedSessionId(valid, digest)).toBe(valid); + const fallback = deriveSessionId(digest("default")); + for (const pinned of ["ses_dsh_default", "", 42, undefined, null]) { + const derived = pinnedSessionId(pinned, digest); + expect(derived, String(pinned)).toBe(fallback); + expect(isOpenCodeSessionId(derived)).toBe(true); + } + }); +}); + +describe("session-id · the shapes the gateway reads", () => { + it("isOpenCodeSessionId accepts ses_ + 12 hex + 14 base62 and nothing else", () => { + const derived = deriveSessionId(digest("shape")); + const table: readonly [unknown, boolean][] = [ + [derived, true], + [`ses_${"a".repeat(12)}${"Z".repeat(14)}`, true], + [`ses_${"A".repeat(12)}${"Z".repeat(14)}`, false], + [`ses_${"a".repeat(12)}${"Z".repeat(13)}`, false], + [`ses_${"a".repeat(12)}${"Z".repeat(15)}`, false], + [`msg_${derived.slice(4)}`, false], + ["ses_dsh_default", false], + ["", false], + [42, false], + [undefined, false], + ]; + for (const [value, accepted] of table) { + expect(isOpenCodeSessionId(value), String(value)).toBe(accepted); + } + }); + + it("isOpenCodeMessageId accepts msg_ + 12 hex + 14 base62 and nothing else", () => { + const suffix = deriveSessionId(digest("message")).slice(4); + const table: readonly [unknown, boolean][] = [ + [`msg_${suffix}`, true], + [`ses_${suffix}`, false], + [`msg_${suffix.slice(1)}`, false], + [`msg_${"g".repeat(12)}${"Z".repeat(14)}`, false], + ["msg_", false], + [null, false], + ]; + for (const [value, accepted] of table) { + expect(isOpenCodeMessageId(value), String(value)).toBe(accepted); + } + }); +}); + +describe("session · which id a request carries", () => { + const VALID = `ses_0123456789ab${"A".repeat(14)}`; + const PINNED = `ses_abcdef012345${"B".repeat(14)}`; + // The "absent" case, named so it reads as a value rather than lint noise. + const ABSENT = undefined; + + it("prefers the turn's own id above everything", () => { + // The only source with certainty: it is the conversation actually running. + expect(sessionIdForRequest(VALID, PINNED, PINNED, digest)).toBe(VALID); + }); + + it("ignores an empty turn id and falls through", () => { + expect(sessionIdForRequest("", PINNED, ABSENT, digest)).toBe(PINNED); + }); + + it("honours a pinned header only when the gateway would accept it", () => { + expect(sessionIdForRequest(ABSENT, PINNED, ABSENT, digest)).toBe(PINNED); + // `ses_dsh_default` starts with `ses_` and is not a session id; sending it + // earns a 401 that reads like a credential fault. + const derived = sessionIdForRequest( + ABSENT, + "ses_dsh_default", + ABSENT, + digest + ); + expect(derived).not.toBe("ses_dsh_default"); + expect(isOpenCodeSessionId(derived)).toBe(true); + }); + + it("prefers a valid pinned header over the environment", () => { + expect(sessionIdForRequest(ABSENT, PINNED, VALID, digest)).toBe(PINNED); + }); + + it("uses a valid pinned env id, and derives when it is not one", () => { + expect(sessionIdForRequest(ABSENT, ABSENT, VALID, digest)).toBe(VALID); + for (const bad of [undefined, null, "", "ses_dsh_default", 42]) { + const derived = sessionIdForRequest(ABSENT, ABSENT, bad, digest); + expect(isOpenCodeSessionId(derived), String(bad)).toBe(true); + } + }); + + it("derives the same default every time, so a conversation stays stable", () => { + expect(sessionIdForRequest(ABSENT, ABSENT, ABSENT, digest)).toBe( + sessionIdForRequest(ABSENT, ABSENT, ABSENT, digest) + ); + }); +}); diff --git a/test/session.test.ts b/test/session.test.ts index 588edd9a..8ddc282d 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -21,7 +21,6 @@ import { newOpenCodeMessageId, openCodeSessionIdFor, readSessionMetaResolver, - sessionIdForRequest, withStore, } from "./support/src.ts"; import { SESSION_RE, createMockStoreStream } from "./test-helpers.ts"; @@ -212,6 +211,17 @@ describe("withStore", () => { }); }); +/** + * The vendor's own MINT (`packages/opencode/src/id/id.ts`), restated: six bytes + * of `~(ms * 0x1000 + counter)` as hex, then fourteen base62. Built here rather + * than captured from a client, so no id in this file is one a real session sent. + */ +const vendorMint = (ms: number, counter: number, suffix: string): string => { + const mask = 2n ** 48n; + const time = (BigInt(ms) * 4096n + BigInt(counter)) % mask; + return `ses_${(mask - 1n - time).toString(16).padStart(12, "0")}${suffix}`; +}; + /** * The vendor PARSES these ids back, so our encoding has to survive their own * reader. These cases run that reader — `timestamp()` from @@ -328,7 +338,9 @@ describe("session: a session id the gateway will accept", () => { it("accepts what the derivation produces and what the client sends", () => { // Measured: the client's own ids are ses_ + 12 hex + 14 base62. expect(isOpenCodeSessionId(openCodeSessionIdFor("abc"))).toBe(true); - expect(isOpenCodeSessionId("ses_ed62aae61ffeA6UwzEFN9YNKk8")).toBe(true); + expect(isOpenCodeSessionId(vendorMint(Date.now(), 1, "A".repeat(14)))).toBe( + true + ); }); it("lands in the admitted band by construction, not by clamping", () => { @@ -433,7 +445,7 @@ describe("newOpenCodeMessageId: the value x-opencode-request carries", () => { "msg_", "msg_short", "msg_ZZZZZZZZZZZZaaaaaaaaaaaaaa", - "ses_ed62aae61ffeA6UwzEFN9YNKk8", + openCodeSessionIdFor("not-a-message"), "", ]) { expect(isOpenCodeMessageId(bad), bad).toBe(false); @@ -442,66 +454,6 @@ describe("newOpenCodeMessageId: the value x-opencode-request carries", () => { }); }); -describe("session · which id a request carries", () => { - const VALID = `ses_0123456789ab${"A".repeat(14)}`; - const PINNED = `ses_abcdef012345${"B".repeat(14)}`; - // The "absent" case, named so it reads as a value rather than lint noise. - const ABSENT = undefined; - // A DETERMINISTIC digest: the point of injecting it is that the rule can be - // exercised without arranging a crypto module or depending on a real hash. - const digest = (value: string) => { - const bytes = new Uint8Array(32); - for (let i = 0; i < value.length; i += 1) { - bytes[i % 32] = (bytes[i % 32] ?? 0) + (value.codePointAt(i) ?? 0); - } - return { - bytes, - hex: [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""), - }; - }; - - it("prefers the turn's own id above everything", () => { - // The only source with certainty: it is the conversation actually running. - expect(sessionIdForRequest(VALID, PINNED, PINNED, digest)).toBe(VALID); - }); - - it("ignores an empty turn id and falls through", () => { - expect(sessionIdForRequest("", PINNED, ABSENT, digest)).toBe(PINNED); - }); - - it("honours a pinned header only when the gateway would accept it", () => { - expect(sessionIdForRequest(ABSENT, PINNED, ABSENT, digest)).toBe(PINNED); - // `ses_dsh_default` starts with `ses_` and is not a session id; sending it - // earns a 401 that reads like a credential fault. - const derived = sessionIdForRequest( - ABSENT, - "ses_dsh_default", - ABSENT, - digest - ); - expect(derived).not.toBe("ses_dsh_default"); - expect(isOpenCodeSessionId(derived)).toBe(true); - }); - - it("prefers a valid pinned header over the environment", () => { - expect(sessionIdForRequest(ABSENT, PINNED, VALID, digest)).toBe(PINNED); - }); - - it("uses a valid pinned env id, and derives when it is not one", () => { - expect(sessionIdForRequest(ABSENT, ABSENT, VALID, digest)).toBe(VALID); - for (const bad of [undefined, null, "", "ses_dsh_default", 42]) { - const derived = sessionIdForRequest(ABSENT, ABSENT, bad, digest); - expect(isOpenCodeSessionId(derived), String(bad)).toBe(true); - } - }); - - it("derives the same default every time, so a conversation stays stable", () => { - expect(sessionIdForRequest(ABSENT, ABSENT, ABSENT, digest)).toBe( - sessionIdForRequest(ABSENT, ABSENT, ABSENT, digest) - ); - }); -}); - const VALID_ENV_ID = `ses_0123456789ab${"C".repeat(14)}`; describe("session · the fallback id reads the environment", () => { diff --git a/test/settings-page-card.test.tsx b/test/settings-page-card.test.tsx new file mode 100644 index 00000000..ee537117 --- /dev/null +++ b/test/settings-page-card.test.tsx @@ -0,0 +1,352 @@ +/** + * The settings page: the OpenCode card as it renders. + * + * @module test/settings-page-card.test + */ +import assert from "node:assert/strict"; + +import { describe, expect, it, vi } from "vitest"; + +import { COMPONENT_BY_KIND, controlsInOrder } from "./support/card-tree.ts"; +import { + applySettingsPage as apply, + CARD_FIELDS, + CONFIG_ONLY_FIELDS, + en, + ENDPOINT_FAMILIES, + KEY_SOURCE_POLICIES, + PKG, +} from "./support/src.ts"; +import { elementName, findAll, firstOf } from "./test-helpers.ts"; + +describe("settings-page: OpencodeCard rendering", () => { + const mountCard = () => { + let cardComponent: + | ((props: Record) => unknown) + | undefined; + const snapshot = { + base: {}, + revision: 1, + status: "ready", + user: {}, + value: { + injectUserAgent: true, + usageEnabled: true, + }, + writable: true, + }; + + const ctx = { + configForms: { + get: () => ({ + getSnapshot: () => snapshot, + mutate: async () => true, + subscribe: () => () => {}, + }), + whileServed: (_namespaces: string[], fn: () => void) => fn(), + }, + effect: (fn: () => unknown) => fn(), + locale: { + bind: () => (k: string) => k, + register: () => () => {}, + }, + inject: (_deps: string[], callback: (scope: unknown) => unknown) => + callback(ctx), + slots: { + inject: (_name: string, fn: () => void) => fn(), + register: (entry: Record, component: unknown) => { + if (entry.key === PKG) { + cardComponent = component as ( + props: Record + ) => unknown; + } + }, + }, + }; + + apply(ctx as never); + assert.ok(cardComponent, "card component registered"); + return cardComponent; + }; + + it("renders summary view as the description string", () => { + const Card = mountCard(); + const result = Card({ + view: "summary", + t: (k: string) => `translated:${k}`, + }) as { + props: { children: unknown }; + }; + expect(result.props.children).toBe("translated:description"); + }); + + it("renders page view with SettingsForm and all configuration fields", () => { + const Card = mountCard(); + const edits: Array<{ field: string; text: string }> = []; + const resets: string[] = []; + const saveMock = vi.fn(); + const discardMock = vi.fn(); + + const state = { + fields: { + injectUserAgent: { invalid: false, overridden: true, text: "true" }, + providers: { + invalid: false, + overridden: false, + text: "opencode, opencode-go", + }, + usageBaseURL: { + invalid: false, + overridden: false, + text: "https://opencode.ai/zen/go/v1", + }, + usageEnabled: { invalid: false, overridden: false, text: "true" }, + }, + shell: { + available: true, + dirty: false, + failed: false, + invalid: false, + saving: false, + writable: true, + }, + }; + + const tree = Card({ + discard: discardMock, + edit: (field: string, text: string) => edits.push({ field, text }), + resetField: (field: string) => resets.push(field), + save: saveMock, + t: (k: string) => k, + useOpencodeCard: (selector: (s: typeof state) => unknown) => + selector(state), + view: "page", + }); + + const form = firstOf(tree, "SettingsForm"); + expect(form).toBeDefined(); + + // Every register entry renders exactly one control: booleans as + // SettingsBooleanField (with Switch inside), the enum as SettingsChoiceField. + const boolFields = findAll(tree, "SettingsBooleanField"); + const choiceFields = findAll(tree, "SettingsChoiceField"); + const valueFields = findAll(tree, "SettingsValueField"); + expect(boolFields.length + choiceFields.length + valueFields.length).toBe( + CARD_FIELDS.length + ); + // The card shows only the decisions a user makes — 7 toggles and 2 enums. + // Every literal/marker override is config-only (see CONFIG_ONLY_FIELDS), so + // no text or list field renders at all. + // Seven booleans: the meter's own switch is one, and there is no nested + // price switch inside it. + expect(boolFields.length).toBe(7); + expect(choiceFields.length).toBe(2); + expect(valueFields.length).toBe(0); + const choiceFieldsById = new Map( + choiceFields.map((field) => [String(field.props.id), field]) + ); + const choiceField = choiceFieldsById.get( + "plugin-config-opencode-keySource" + ); + assert.ok(choiceField, "expected a keySource SettingsChoiceField"); + // The enum's choices are copy-resolved before they reach the control. + expect( + (choiceField.props.options as { label: string; value: string }[]).map( + (option) => option.value + ) + ).toEqual([...KEY_SOURCE_POLICIES]); + // The endpoint family's choices, from the same table `config-values` exports. + const endpointField = choiceFieldsById.get( + "plugin-config-opencode-endpoint" + ); + assert.ok(endpointField, "expected an endpoint SettingsChoiceField"); + expect( + (endpointField.props.options as { label: string; value: string }[]).map( + (option) => option.value + ) + ).toEqual([...ENDPOINT_FAMILIES]); + + // No config-only knob may appear as a control: the simplified UI has to stay + // simplified, and an override must not become editable by accident. + const renderedIds = [...boolFields, ...choiceFields].map((f) => f.props.id); + for (const { field } of CONFIG_ONLY_FIELDS) { + expect(renderedIds).not.toContain(`plugin-config-opencode-${field}`); + } + + // Test boolean field edit, addressed by id rather than by position: the + // render order is the card's own decision, and a positional read breaks the + // moment a field moves between sections. + const userAgentField = boolFields.find( + (field) => field.props.id === "plugin-config-opencode-injectUserAgent" + ); + assert.ok( + userAgentField, + "expected an injectUserAgent SettingsBooleanField" + ); + const onBoolEdit = userAgentField.props.onEdit as (text: string) => void; + onBoolEdit("false"); + expect(edits).toContainEqual({ field: "injectUserAgent", text: "false" }); + + // Test enum edit: the select stages the raw option value. + const onChoiceEdit = choiceField.props.onEdit as (text: string) => void; + onChoiceEdit("request"); + expect(edits).toContainEqual({ field: "keySource", text: "request" }); + + // Test reset callback + const onReset = userAgentField.props.onReset as () => void; + onReset(); + expect(resets).toContain("injectUserAgent"); + }); + + it("disables fields when writable is false", () => { + const Card = mountCard(); + + const state = { + fields: {}, + shell: { + available: true, + dirty: false, + failed: false, + invalid: false, + saving: false, + writable: false, + }, + }; + + const tree = Card({ + discard: () => {}, + edit: () => {}, + resetField: () => {}, + save: () => {}, + t: (k: string) => k, + useOpencodeCard: (selector: (s: typeof state) => unknown) => + selector(state), + view: "page", + }); + + const valueFields = findAll(tree, "SettingsValueField"); + for (const f of valueFields) { + expect(f.props.disabled).toBe(true); + } + const boolFields = findAll(tree, "SettingsBooleanField"); + for (const f of boolFields) { + expect(f.props.disabled).toBe(true); + } + }); + + const renderPage = (Card: ReturnType) => { + const state = { + fields: {}, + shell: { + available: true, + dirty: false, + failed: false, + invalid: false, + saving: false, + writable: true, + }, + }; + return Card({ + discard: () => {}, + edit: () => {}, + resetField: () => {}, + save: () => {}, + t: (k: string) => k, + useOpencodeCard: (selector: (s: typeof state) => unknown) => + selector(state), + view: "page", + }); + }; + + it("renders one control per register entry, in register order", () => { + const tree = renderPage(mountCard()); + const controls = controlsInOrder(tree); + + // The card's field list is the register's, so it cannot drift again. + expect(controls.map((f) => f.props.id)).toEqual( + CARD_FIELDS.map((entry) => `plugin-config-opencode-${entry.field}`) + ); + // The control kind follows the register's `kind`, not a second list. + expect(controls.map((f) => elementName(f.type))).toEqual( + CARD_FIELDS.map((entry) => COMPONENT_BY_KIND[entry.kind]) + ); + // Labels come from the register's copy keys, through the card's translator, + // so the two cannot disagree. The mock seat translates nothing (it returns + // the key), which is why this also pins the bundle fallback: a control whose + // copy the seat cannot render must still reach the screen as words. + expect(controls.map((f) => f.props.label)).toEqual( + CARD_FIELDS.map((entry) => en[entry.labelKey]) + ); + }); + + it("offers exactly the key-source policies the host accepts", () => { + // The select's values and the config enum are two lists in two modules; a + // value offered here but not accepted there would be silently coerced back + // to the default on save. + const entry = CARD_FIELDS.find((field) => field.field === "keySource"); + assert.ok(entry, "expected a keySource register entry"); + expect(entry.kind).toBe("select"); + expect((entry.options ?? []).map((option) => option.value)).toEqual([ + ...KEY_SOURCE_POLICIES, + ]); + }); + + it("renders the documented enrichModels switch", () => { + // Regression guard: it was declared in the spec register and translated + // (en + zh) but omitted from the card's hand-written JSX, so the switch + // documented in the README could not be reached from the UI. + const ids = controlsInOrder(renderPage(mountCard())).map((f) => f.props.id); + expect(ids).toContain("plugin-config-opencode-enrichModels"); + }); + + it("renders one heading per group, in register order", () => { + const headings = findAll(renderPage(mountCard()), "GroupHeading"); + const groups = [...new Set(CARD_FIELDS.map((entry) => entry.group))]; + expect(headings).toHaveLength(groups.length); + expect(headings.map((heading) => heading.props.label)).toEqual( + groups.map((group) => en[group]) + ); + // Only the first heading drops the separator rule. + expect(headings.map((heading) => heading.props.first)).toEqual( + groups.map((_, index) => index === 0) + ); + }); + + it("wires each control's edit and reset back to the form model", () => { + // The card hands the host an id, a label and two callbacks per control. The + // ids and labels were asserted; the callbacks were not, so a control could + // render perfectly and do nothing. + const edit = vi.fn(); + const resetField = vi.fn(); + const state = { + fields: {}, + shell: { + available: true, + dirty: false, + failed: false, + invalid: false, + saving: false, + writable: true, + }, + }; + const Card = mountCard(); + const tree = Card({ + discard: () => {}, + edit, + resetField, + save: () => {}, + t: (k: string) => k, + useOpencodeCard: (selector: (s: typeof state) => unknown) => + selector(state), + view: "page", + }); + + const [first] = controlsInOrder(tree); + const name = CARD_FIELDS[0]?.field ?? ""; + assert.ok(first, "expected at least one control"); + (first.props.onEdit as (text: string) => void)("draft"); + (first.props.onReset as () => void)(); + expect(edit).toHaveBeenCalledWith(name, "draft"); + expect(resetField).toHaveBeenCalledWith(name); + }); +}); diff --git a/test/settings-page-fields.test.tsx b/test/settings-page-fields.test.tsx new file mode 100644 index 00000000..1be7ff8a --- /dev/null +++ b/test/settings-page-fields.test.tsx @@ -0,0 +1,122 @@ +/** + * The settings page: the field register and each field's spec. + * + * @module test/settings-page-fields.test + */ +import assert from "node:assert/strict"; + +import { describe, expect, it } from "vitest"; + +import { + CARD_FIELDS, + Config, + CONFIG_ONLY_FIELDS, + SPECS, +} from "./support/src.ts"; + +describe("settings-page: field register", () => { + it("derives SPECS from CARD_FIELDS in the same order", () => { + expect(SPECS.map((s) => s.field)).toEqual(CARD_FIELDS.map((f) => f.field)); + }); + + it("gives every register entry a usable draft conversion", () => { + for (const entry of CARD_FIELDS) { + const spec = SPECS.find((s) => s.field === entry.field); + assert.ok(spec, `expected a spec for ${entry.field}`); + expect(typeof spec.format).toBe("function"); + expect(typeof spec.parse).toBe("function"); + } + }); + + it("declares each field at most once", () => { + const fields = CARD_FIELDS.map((entry) => entry.field); + expect(new Set(fields).size).toBe(fields.length); + }); +}); + +describe("settings-page: field specs", () => { + const specOf = (field: string) => { + const found = SPECS.find((s) => s.field === field); + assert.ok(found, `expected a spec for ${field}`); + return found; + }; + + it("covers every field the card renders, in render order", () => { + expect(SPECS.map((s) => s.field)).toEqual([ + "endpoint", + "keySource", + "readOpencodeStore", + "injectUserAgent", + "injectOriginHeaders", + "injectProject", + "enrichModels", + "injectCoreTools", + "usageEnabled", + ]); + }); + + it("partitions the schema into card fields and config-only knobs", () => { + // The card deliberately hides the override knobs, so the two lists must + // together cover every schema key exactly once. A knob in neither would be + // unreachable; a knob in both would contradict CONFIG_ONLY_FIELDS. + // + // The keys come from `Config.dict`, the schema's own field table. + // `Config({})` materialises only the keys that carry a default or are + // `.volatile()`, which is how `debugFile` (neither) went missing from both + // lists without this test noticing. + // oxlint-disable-next-line unicorn/no-array-sort -- `Object.keys` returns a fresh array, so in-place sort mutates nothing shared. + const schemaKeys = Object.keys(Config.dict ?? {}).sort(); + expect(schemaKeys).toContain("debugFile"); + const cardFields = CARD_FIELDS.map((entry) => entry.field); + const configOnly = CONFIG_ONLY_FIELDS.map((entry) => entry.field); + const covered = [...cardFields, ...configOnly]; + + // oxlint-disable-next-line unicorn/no-array-sort -- the spread above is a fresh array, so nothing shared is mutated. + expect(covered.sort()).toEqual(schemaKeys); + expect(new Set(covered).size).toBe(covered.length); + for (const entry of CONFIG_ONLY_FIELDS) { + expect(entry.reason.length).toBeGreaterThan(0); + } + }); + + it("keeps each group's fields contiguous", () => { + // The card inserts a heading wherever the group changes, so a field whose + // group is not adjacent to its siblings would render the heading twice. + const seen = new Set(); + let previous: string | undefined; + for (const entry of CARD_FIELDS) { + if (entry.group !== previous) { + expect( + seen.has(entry.group), + `${entry.field} reopens group ${entry.group} after it closed` + ).toBe(false); + seen.add(entry.group); + previous = entry.group; + } + } + expect(seen.size).toBeGreaterThan(1); + }); + + it("keeps the boolean kind on its stock semantics", () => { + const inject = specOf("injectCoreTools"); + expect(inject.format(true)).toBe("true"); + expect(inject.format("not-a-boolean")).toBe(""); + expect(inject.parse("TRUE")).toEqual({ kind: "set", value: true }); + expect(inject.parse("")).toEqual({ kind: "clear" }); + expect(inject.parse("maybe")).toBeUndefined(); + }); + + it("refuses a select draft outside the option set", () => { + // The enum is the card's only non-boolean kind, so its conversion is the one + // that must reject a value the schema would not accept. + const source = specOf("keySource"); + expect(source.format("request")).toBe("request"); + expect(source.format(42)).toBe(""); + expect(source.parse("configured")).toEqual({ + kind: "set", + value: "configured", + }); + expect(source.parse("")).toEqual({ kind: "clear" }); + expect(source.parse("hand-edited")).toBeUndefined(); + }); +}); diff --git a/test/settings-page-remotes.test.tsx b/test/settings-page-remotes.test.tsx new file mode 100644 index 00000000..3fd8740d --- /dev/null +++ b/test/settings-page-remotes.test.tsx @@ -0,0 +1,221 @@ +/** + * The settings page: every remote the card reads gets a client mount. + * + * @module test/settings-page-remotes.test + */ +import { describe, expect, it } from "vitest"; + +import { + applySettingsPage as apply, + remoteNamespace, + remoteNamespaceOf, +} from "./support/src.ts"; + +describe("settings-page: every remote gets a mount", () => { + // The bug this exists for: `loginRemote` was exported, tested on its own, and + // never mounted by the page, so the card's `ctx.remote.opencodeLogin` did not + // exist and the sign-in row rendered nothing - silently, because a missing + // remote and a remote with nothing to say look identical from the outside. + // Nothing asserted which remotes the page mounts, so nothing noticed. + it("mounts one per surface the card reads, and no surface is left out", async () => { + const mounted: { package?: string; descriptors: { service: string }[] }[] = + []; + const ctx = { + effect: (fn: () => unknown) => fn(), + remote: { + $mount: async (contribution: unknown) => { + mounted.push( + contribution as { + package?: string; + descriptors: { service: string }[]; + } + ); + return () => {}; + }, + }, + }; + + apply(ctx as never); + // The mount is async: `mountRemote` starts it and the effect returns the + // disposer, so let the microtask queue drain before reading the list. + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + // Every namespace the CARD actually calls must be mounted here, or its + // surface reports "unavailable" for a host that has the service — which is + // exactly what the purge row did before its descriptor existed. The sign-in + // row and its `opencodeLogin` namespace are v2 work: main carries the API key + // path only, so nothing here should depend on a login service existing. + const services = mounted.flatMap((contribution) => + contribution.descriptors.map((d) => d.service) + ); + // `opencodeStore` is the inspection row's; it was the one left out. + expect(services).toHaveLength(3); + expect(new Set(services)).toEqual( + new Set(["opencodeGoUsage", "opencodeModelPurge", "opencodeStore"]) + ); + expect(mounted).toHaveLength(1); + expect(mounted[0]?.package).toBe("dsh-opencode-patch"); + }); + + it("registers the meter when the mount settles, not on a service name that never appears", async () => { + const injected: { slot: string; inject?: unknown }[] = []; + const unmounts: (() => void)[] = []; + // The scope the host hands the inject callback: a child that already sees + // the three named services. Only `slots` matters here, because the injector + // the slot registration returns is not called until the Host renders it. + const scope = { + slots: { + inject: (slot: string) => { + injected.push({ slot }); + return () => {}; + }, + register: (_entry: Record) => () => {}, + }, + }; + const ctx = { + effect: (fn: () => unknown) => { + const dispose = fn(); + if (typeof dispose === "function") { + unmounts.push(dispose as () => void); + } + return dispose; + }, + inject: (deps: string[], cb: (scope: unknown) => unknown) => { + // The scoped wait must NOT name the remote namespace: it is a child of + // the Host remote face, not a service, so naming it waits forever — the + // meter then only ever appeared through a ten-second timeout. + expect(deps).not.toContain("remote.opencodeGoUsage"); + return cb(scope); + }, + locale: { register: () => () => {} }, + remote: { + $mount: async () => () => {}, + }, + slots: scope.slots, + }; + + apply(ctx as never); + // Both paths call `registerMeter`: the scoped inject when the three named + // services exist, and the mount's settlement. The second is a no-op, so the + // slot is registered exactly once however many paths reach it. + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(injected.map((entry) => entry.slot)).toEqual([ + "conversation.input.right", + ]); + expect(unmounts.length).toBeGreaterThan(0); + for (const unmount of unmounts) { + unmount(); + } + }); + + it("reads the usage namespace through ctx.get, not the remote property", async () => { + // Measured in a browser console, after the contribution mounted: + // property read: false; ctx.get('remote.opencodeGoUsage'): object + // + // `remote.` is a Cordis service property, so reading it needs the name in + // `inject` — a hard gate that deadlocks when this same bundle is what mounts + // it. The property read throws, the guard answers `undefined`, and the meter + // reported "no usage service" for a service that was mounted and answering. + // `ctx.get` does not consult `inject`, so it is the read that works. + const usage = { read: async () => ({ ok: true, value: {} }) }; + const scope = { + get: (name: string) => + name === "remote.opencodeGoUsage" ? usage : undefined, + // The property read is REFUSED, exactly as Cordis refuses it. + remote: new Proxy( + {}, + { + get: () => { + throw new Error( + 'cannot get property "remote.opencodeGoUsage" without inject' + ); + }, + } + ), + }; + expect(remoteNamespaceOf(scope, "opencodeGoUsage")).toBe(usage); + // And the property path on its own really is unusable here. + expect(remoteNamespace(scope.remote, "opencodeGoUsage")).toBeUndefined(); + }); + + it("does not spend the one registration on a scope without `slots`", async () => { + // The failure this guards: `registerMeter` is reached from three places, and + // the mount's settlement hands over the ROOT context, which need not carry + // `slots`. Marking the one-shot guard before the attempt meant a path that + // could not register still consumed the registration — the scoped inject then + // found the guard set and returned, and the pill never appeared anywhere. + // `?.` swallowed the no-op, so nothing warned. + const injected: string[] = []; + const child = { + slots: { + inject: (slot: string) => { + injected.push(slot); + return () => {}; + }, + register: () => () => {}, + }, + }; + const ctx = { + effect: (fn: () => unknown) => fn(), + // The scoped inject fires AFTER the mount has settled, with a scope that + // does carry `slots` — the ordering that used to lose the meter. + inject: (_names: string[], cb: (scope: unknown) => void) => { + setTimeout(() => { + cb(child); + }, 5); + return () => {}; + }, + locale: { register: () => () => {} }, + remote: { $mount: async () => () => {} }, + // The root has no `slots` at all. + }; + + apply(ctx as never); + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + expect(injected).toEqual(["conversation.input.right"]); + }); + + it("registers the meter from the mount alone when the scoped inject never fires", async () => { + // The case the ten-second timeout used to cover. `ctx.inject` is present but + // its services never appear, so the ONLY way the meter can show up is the + // mount settling — which is what the timeout used to stand in for. + const injected: string[] = []; + const ctx = { + effect: (fn: () => unknown) => fn(), + // Registered, never called: the Host has no model directory to hand over. + inject: () => () => {}, + locale: { register: () => () => {} }, + remote: { + // A mount that resolves on a later macrotask, so the settlement is + // genuinely asynchronous rather than already done. + $mount: async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + return () => {}; + }, + }, + slots: { + inject: (slot: string) => { + injected.push(slot); + return () => {}; + }, + register: (_entry: Record) => () => {}, + }, + }; + + apply(ctx as never); + // Not yet: the mount has not settled. + expect(injected).toEqual([]); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(injected).toEqual(["conversation.input.right"]); + }); +}); diff --git a/test/settings-page.test.tsx b/test/settings-page.test.tsx index 0ddeb9ec..a778bd9b 100644 --- a/test/settings-page.test.tsx +++ b/test/settings-page.test.tsx @@ -1,44 +1,21 @@ -import assert from "node:assert/strict"; - +/** + * The settings page: `apply`, its dictionaries and its slots. + * + * The card's rendering, the field register and the remote mounts are their + * own files (`settings-page-card`, `settings-page-fields`, + * `settings-page-remotes`). + * + * @module test/settings-page.test + */ import { describe, expect, it, vi } from "vitest"; import { applySettingsPage as apply, - CARD_FIELDS, - Config, - CONFIG_ONLY_FIELDS, - en, - ENDPOINT_FAMILIES, - KEY_SOURCE_POLICIES, LEGACY_NS, NS, PKG, - remoteNamespace, - remoteNamespaceOf, SCOPED_PKG, - SPECS, } from "./support/src.ts"; -import { - elementName, - findAll, - findAllWhere, - firstOf, - type TestElement, -} from "./test-helpers.ts"; - -/** Every control component the card can render, keyed by the register's `kind`. */ -const COMPONENT_BY_KIND = { - boolean: "SettingsBooleanField", - select: "SettingsChoiceField", -} as const; - -const CONTROL_TYPES: ReadonlySet = new Set( - Object.values(COMPONENT_BY_KIND) -); - -/** The card's controls, in document order. */ -const controlsInOrder = (tree: unknown): TestElement[] => - findAllWhere(tree, (element) => CONTROL_TYPES.has(elementName(element.type))); describe("settings-page: apply & slots", () => { it("registers dictionaries for modern and legacy namespaces without throwing", () => { @@ -832,651 +809,3 @@ describe("settings-page: apply & slots", () => { expect(() => apply(ctx as never)).not.toThrow(); }); }); - -describe("settings-page: OpencodeCard rendering", () => { - const mountCard = () => { - let cardComponent: - | ((props: Record) => unknown) - | undefined; - const snapshot = { - base: {}, - revision: 1, - status: "ready", - user: {}, - value: { - injectUserAgent: true, - usageEnabled: true, - }, - writable: true, - }; - - const ctx = { - configForms: { - get: () => ({ - getSnapshot: () => snapshot, - mutate: async () => true, - subscribe: () => () => {}, - }), - whileServed: (_namespaces: string[], fn: () => void) => fn(), - }, - effect: (fn: () => unknown) => fn(), - locale: { - bind: () => (k: string) => k, - register: () => () => {}, - }, - inject: (_deps: string[], callback: (scope: unknown) => unknown) => - callback(ctx), - slots: { - inject: (_name: string, fn: () => void) => fn(), - register: (entry: Record, component: unknown) => { - if (entry.key === PKG) { - cardComponent = component as ( - props: Record - ) => unknown; - } - }, - }, - }; - - apply(ctx as never); - assert.ok(cardComponent, "card component registered"); - return cardComponent; - }; - - it("renders summary view as the description string", () => { - const Card = mountCard(); - const result = Card({ - view: "summary", - t: (k: string) => `translated:${k}`, - }) as { - props: { children: unknown }; - }; - expect(result.props.children).toBe("translated:description"); - }); - - it("renders page view with SettingsForm and all configuration fields", () => { - const Card = mountCard(); - const edits: Array<{ field: string; text: string }> = []; - const resets: string[] = []; - const saveMock = vi.fn(); - const discardMock = vi.fn(); - - const state = { - fields: { - injectUserAgent: { invalid: false, overridden: true, text: "true" }, - providers: { - invalid: false, - overridden: false, - text: "opencode, opencode-go", - }, - usageBaseURL: { - invalid: false, - overridden: false, - text: "https://opencode.ai/zen/go/v1", - }, - usageEnabled: { invalid: false, overridden: false, text: "true" }, - }, - shell: { - available: true, - dirty: false, - failed: false, - invalid: false, - saving: false, - writable: true, - }, - }; - - const tree = Card({ - discard: discardMock, - edit: (field: string, text: string) => edits.push({ field, text }), - resetField: (field: string) => resets.push(field), - save: saveMock, - t: (k: string) => k, - useOpencodeCard: (selector: (s: typeof state) => unknown) => - selector(state), - view: "page", - }); - - const form = firstOf(tree, "SettingsForm"); - expect(form).toBeDefined(); - - // Every register entry renders exactly one control: booleans as - // SettingsBooleanField (with Switch inside), the enum as SettingsChoiceField. - const boolFields = findAll(tree, "SettingsBooleanField"); - const choiceFields = findAll(tree, "SettingsChoiceField"); - const valueFields = findAll(tree, "SettingsValueField"); - expect(boolFields.length + choiceFields.length + valueFields.length).toBe( - CARD_FIELDS.length - ); - // The card shows only the decisions a user makes — 7 toggles and 2 enums. - // Every literal/marker override is config-only (see CONFIG_ONLY_FIELDS), so - // no text or list field renders at all. - // Seven booleans: the meter's own switch is one, and there is no nested - // price switch inside it. - expect(boolFields.length).toBe(7); - expect(choiceFields.length).toBe(2); - expect(valueFields.length).toBe(0); - const choiceFieldsById = new Map( - choiceFields.map((field) => [String(field.props.id), field]) - ); - const choiceField = choiceFieldsById.get( - "plugin-config-opencode-keySource" - ); - assert.ok(choiceField, "expected a keySource SettingsChoiceField"); - // The enum's choices are copy-resolved before they reach the control. - expect( - (choiceField.props.options as { label: string; value: string }[]).map( - (option) => option.value - ) - ).toEqual([...KEY_SOURCE_POLICIES]); - // The endpoint family's choices, from the same table `config-values` exports. - const endpointField = choiceFieldsById.get( - "plugin-config-opencode-endpoint" - ); - assert.ok(endpointField, "expected an endpoint SettingsChoiceField"); - expect( - (endpointField.props.options as { label: string; value: string }[]).map( - (option) => option.value - ) - ).toEqual([...ENDPOINT_FAMILIES]); - - // No config-only knob may appear as a control: the simplified UI has to stay - // simplified, and an override must not become editable by accident. - const renderedIds = [...boolFields, ...choiceFields].map((f) => f.props.id); - for (const { field } of CONFIG_ONLY_FIELDS) { - expect(renderedIds).not.toContain(`plugin-config-opencode-${field}`); - } - - // Test boolean field edit, addressed by id rather than by position: the - // render order is the card's own decision, and a positional read breaks the - // moment a field moves between sections. - const userAgentField = boolFields.find( - (field) => field.props.id === "plugin-config-opencode-injectUserAgent" - ); - assert.ok( - userAgentField, - "expected an injectUserAgent SettingsBooleanField" - ); - const onBoolEdit = userAgentField.props.onEdit as (text: string) => void; - onBoolEdit("false"); - expect(edits).toContainEqual({ field: "injectUserAgent", text: "false" }); - - // Test enum edit: the select stages the raw option value. - const onChoiceEdit = choiceField.props.onEdit as (text: string) => void; - onChoiceEdit("request"); - expect(edits).toContainEqual({ field: "keySource", text: "request" }); - - // Test reset callback - const onReset = userAgentField.props.onReset as () => void; - onReset(); - expect(resets).toContain("injectUserAgent"); - }); - - it("disables fields when writable is false", () => { - const Card = mountCard(); - - const state = { - fields: {}, - shell: { - available: true, - dirty: false, - failed: false, - invalid: false, - saving: false, - writable: false, - }, - }; - - const tree = Card({ - discard: () => {}, - edit: () => {}, - resetField: () => {}, - save: () => {}, - t: (k: string) => k, - useOpencodeCard: (selector: (s: typeof state) => unknown) => - selector(state), - view: "page", - }); - - const valueFields = findAll(tree, "SettingsValueField"); - for (const f of valueFields) { - expect(f.props.disabled).toBe(true); - } - const boolFields = findAll(tree, "SettingsBooleanField"); - for (const f of boolFields) { - expect(f.props.disabled).toBe(true); - } - }); - - const renderPage = (Card: ReturnType) => { - const state = { - fields: {}, - shell: { - available: true, - dirty: false, - failed: false, - invalid: false, - saving: false, - writable: true, - }, - }; - return Card({ - discard: () => {}, - edit: () => {}, - resetField: () => {}, - save: () => {}, - t: (k: string) => k, - useOpencodeCard: (selector: (s: typeof state) => unknown) => - selector(state), - view: "page", - }); - }; - - it("renders one control per register entry, in register order", () => { - const tree = renderPage(mountCard()); - const controls = controlsInOrder(tree); - - // The card's field list is the register's, so it cannot drift again. - expect(controls.map((f) => f.props.id)).toEqual( - CARD_FIELDS.map((entry) => `plugin-config-opencode-${entry.field}`) - ); - // The control kind follows the register's `kind`, not a second list. - expect(controls.map((f) => elementName(f.type))).toEqual( - CARD_FIELDS.map((entry) => COMPONENT_BY_KIND[entry.kind]) - ); - // Labels come from the register's copy keys, through the card's translator, - // so the two cannot disagree. The mock seat translates nothing (it returns - // the key), which is why this also pins the bundle fallback: a control whose - // copy the seat cannot render must still reach the screen as words. - expect(controls.map((f) => f.props.label)).toEqual( - CARD_FIELDS.map((entry) => en[entry.labelKey]) - ); - }); - - it("offers exactly the key-source policies the host accepts", () => { - // The select's values and the config enum are two lists in two modules; a - // value offered here but not accepted there would be silently coerced back - // to the default on save. - const entry = CARD_FIELDS.find((field) => field.field === "keySource"); - assert.ok(entry, "expected a keySource register entry"); - expect(entry.kind).toBe("select"); - expect((entry.options ?? []).map((option) => option.value)).toEqual([ - ...KEY_SOURCE_POLICIES, - ]); - }); - - it("renders the documented enrichModels switch", () => { - // Regression guard: it was declared in the spec register and translated - // (en + zh) but omitted from the card's hand-written JSX, so the switch - // documented in the README could not be reached from the UI. - const ids = controlsInOrder(renderPage(mountCard())).map((f) => f.props.id); - expect(ids).toContain("plugin-config-opencode-enrichModels"); - }); - - it("renders one heading per group, in register order", () => { - const headings = findAll(renderPage(mountCard()), "GroupHeading"); - const groups = [...new Set(CARD_FIELDS.map((entry) => entry.group))]; - expect(headings).toHaveLength(groups.length); - expect(headings.map((heading) => heading.props.label)).toEqual( - groups.map((group) => en[group]) - ); - // Only the first heading drops the separator rule. - expect(headings.map((heading) => heading.props.first)).toEqual( - groups.map((_, index) => index === 0) - ); - }); - - it("wires each control's edit and reset back to the form model", () => { - // The card hands the host an id, a label and two callbacks per control. The - // ids and labels were asserted; the callbacks were not, so a control could - // render perfectly and do nothing. - const edit = vi.fn(); - const resetField = vi.fn(); - const state = { - fields: {}, - shell: { - available: true, - dirty: false, - failed: false, - invalid: false, - saving: false, - writable: true, - }, - }; - const Card = mountCard(); - const tree = Card({ - discard: () => {}, - edit, - resetField, - save: () => {}, - t: (k: string) => k, - useOpencodeCard: (selector: (s: typeof state) => unknown) => - selector(state), - view: "page", - }); - - const [first] = controlsInOrder(tree); - const name = CARD_FIELDS[0]?.field ?? ""; - assert.ok(first, "expected at least one control"); - (first.props.onEdit as (text: string) => void)("draft"); - (first.props.onReset as () => void)(); - expect(edit).toHaveBeenCalledWith(name, "draft"); - expect(resetField).toHaveBeenCalledWith(name); - }); -}); - -describe("settings-page: field register", () => { - it("derives SPECS from CARD_FIELDS in the same order", () => { - expect(SPECS.map((s) => s.field)).toEqual(CARD_FIELDS.map((f) => f.field)); - }); - - it("gives every register entry a usable draft conversion", () => { - for (const entry of CARD_FIELDS) { - const spec = SPECS.find((s) => s.field === entry.field); - assert.ok(spec, `expected a spec for ${entry.field}`); - expect(typeof spec.format).toBe("function"); - expect(typeof spec.parse).toBe("function"); - } - }); - - it("declares each field at most once", () => { - const fields = CARD_FIELDS.map((entry) => entry.field); - expect(new Set(fields).size).toBe(fields.length); - }); -}); - -describe("settings-page: field specs", () => { - const specOf = (field: string) => { - const found = SPECS.find((s) => s.field === field); - assert.ok(found, `expected a spec for ${field}`); - return found; - }; - - it("covers every field the card renders, in render order", () => { - expect(SPECS.map((s) => s.field)).toEqual([ - "endpoint", - "keySource", - "readOpencodeStore", - "injectUserAgent", - "injectOriginHeaders", - "injectProject", - "enrichModels", - "injectCoreTools", - "usageEnabled", - ]); - }); - - it("partitions the schema into card fields and config-only knobs", () => { - // The card deliberately hides the override knobs, so the two lists must - // together cover every schema key exactly once. A knob in neither would be - // unreachable; a knob in both would contradict CONFIG_ONLY_FIELDS. - // - // The keys come from `Config.dict`, the schema's own field table. - // `Config({})` materialises only the keys that carry a default or are - // `.volatile()`, which is how `debugFile` (neither) went missing from both - // lists without this test noticing. - // oxlint-disable-next-line unicorn/no-array-sort -- `Object.keys` returns a fresh array, so in-place sort mutates nothing shared. - const schemaKeys = Object.keys(Config.dict ?? {}).sort(); - expect(schemaKeys).toContain("debugFile"); - const cardFields = CARD_FIELDS.map((entry) => entry.field); - const configOnly = CONFIG_ONLY_FIELDS.map((entry) => entry.field); - const covered = [...cardFields, ...configOnly]; - - // oxlint-disable-next-line unicorn/no-array-sort -- the spread above is a fresh array, so nothing shared is mutated. - expect(covered.sort()).toEqual(schemaKeys); - expect(new Set(covered).size).toBe(covered.length); - for (const entry of CONFIG_ONLY_FIELDS) { - expect(entry.reason.length).toBeGreaterThan(0); - } - }); - - it("keeps each group's fields contiguous", () => { - // The card inserts a heading wherever the group changes, so a field whose - // group is not adjacent to its siblings would render the heading twice. - const seen = new Set(); - let previous: string | undefined; - for (const entry of CARD_FIELDS) { - if (entry.group !== previous) { - expect( - seen.has(entry.group), - `${entry.field} reopens group ${entry.group} after it closed` - ).toBe(false); - seen.add(entry.group); - previous = entry.group; - } - } - expect(seen.size).toBeGreaterThan(1); - }); - - it("keeps the boolean kind on its stock semantics", () => { - const inject = specOf("injectCoreTools"); - expect(inject.format(true)).toBe("true"); - expect(inject.format("not-a-boolean")).toBe(""); - expect(inject.parse("TRUE")).toEqual({ kind: "set", value: true }); - expect(inject.parse("")).toEqual({ kind: "clear" }); - expect(inject.parse("maybe")).toBeUndefined(); - }); - - it("refuses a select draft outside the option set", () => { - // The enum is the card's only non-boolean kind, so its conversion is the one - // that must reject a value the schema would not accept. - const source = specOf("keySource"); - expect(source.format("request")).toBe("request"); - expect(source.format(42)).toBe(""); - expect(source.parse("configured")).toEqual({ - kind: "set", - value: "configured", - }); - expect(source.parse("")).toEqual({ kind: "clear" }); - expect(source.parse("hand-edited")).toBeUndefined(); - }); -}); - -describe("settings-page: every remote gets a mount", () => { - // The bug this exists for: `loginRemote` was exported, tested on its own, and - // never mounted by the page, so the card's `ctx.remote.opencodeLogin` did not - // exist and the sign-in row rendered nothing - silently, because a missing - // remote and a remote with nothing to say look identical from the outside. - // Nothing asserted which remotes the page mounts, so nothing noticed. - it("mounts one per surface the card reads, and no surface is left out", async () => { - const mounted: { package?: string; descriptors: { service: string }[] }[] = - []; - const ctx = { - effect: (fn: () => unknown) => fn(), - remote: { - $mount: async (contribution: unknown) => { - mounted.push( - contribution as { - package?: string; - descriptors: { service: string }[]; - } - ); - return () => {}; - }, - }, - }; - - apply(ctx as never); - // The mount is async: `mountRemote` starts it and the effect returns the - // disposer, so let the microtask queue drain before reading the list. - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - - // Every namespace the CARD actually calls must be mounted here, or its - // surface reports "unavailable" for a host that has the service — which is - // exactly what the purge row did before its descriptor existed. The sign-in - // row and its `opencodeLogin` namespace are v2 work: main carries the API key - // path only, so nothing here should depend on a login service existing. - const services = mounted.flatMap((contribution) => - contribution.descriptors.map((d) => d.service) - ); - // `opencodeStore` is the inspection row's; it was the one left out. - expect(services).toHaveLength(3); - expect(new Set(services)).toEqual( - new Set(["opencodeGoUsage", "opencodeModelPurge", "opencodeStore"]) - ); - expect(mounted).toHaveLength(1); - expect(mounted[0]?.package).toBe("dsh-opencode-patch"); - }); - - it("registers the meter when the mount settles, not on a service name that never appears", async () => { - const injected: { slot: string; inject?: unknown }[] = []; - const unmounts: (() => void)[] = []; - // The scope the host hands the inject callback: a child that already sees - // the three named services. Only `slots` matters here, because the injector - // the slot registration returns is not called until the Host renders it. - const scope = { - slots: { - inject: (slot: string) => { - injected.push({ slot }); - return () => {}; - }, - register: (_entry: Record) => () => {}, - }, - }; - const ctx = { - effect: (fn: () => unknown) => { - const dispose = fn(); - if (typeof dispose === "function") { - unmounts.push(dispose as () => void); - } - return dispose; - }, - inject: (deps: string[], cb: (scope: unknown) => unknown) => { - // The scoped wait must NOT name the remote namespace: it is a child of - // the Host remote face, not a service, so naming it waits forever — the - // meter then only ever appeared through a ten-second timeout. - expect(deps).not.toContain("remote.opencodeGoUsage"); - return cb(scope); - }, - locale: { register: () => () => {} }, - remote: { - $mount: async () => () => {}, - }, - slots: scope.slots, - }; - - apply(ctx as never); - // Both paths call `registerMeter`: the scoped inject when the three named - // services exist, and the mount's settlement. The second is a no-op, so the - // slot is registered exactly once however many paths reach it. - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - expect(injected.map((entry) => entry.slot)).toEqual([ - "conversation.input.right", - ]); - expect(unmounts.length).toBeGreaterThan(0); - for (const unmount of unmounts) { - unmount(); - } - }); - - it("reads the usage namespace through ctx.get, not the remote property", async () => { - // Measured in a browser console, after the contribution mounted: - // property read: false; ctx.get('remote.opencodeGoUsage'): object - // - // `remote.` is a Cordis service property, so reading it needs the name in - // `inject` — a hard gate that deadlocks when this same bundle is what mounts - // it. The property read throws, the guard answers `undefined`, and the meter - // reported "no usage service" for a service that was mounted and answering. - // `ctx.get` does not consult `inject`, so it is the read that works. - const usage = { read: async () => ({ ok: true, value: {} }) }; - const scope = { - get: (name: string) => - name === "remote.opencodeGoUsage" ? usage : undefined, - // The property read is REFUSED, exactly as Cordis refuses it. - remote: new Proxy( - {}, - { - get: () => { - throw new Error( - 'cannot get property "remote.opencodeGoUsage" without inject' - ); - }, - } - ), - }; - expect(remoteNamespaceOf(scope, "opencodeGoUsage")).toBe(usage); - // And the property path on its own really is unusable here. - expect(remoteNamespace(scope.remote, "opencodeGoUsage")).toBeUndefined(); - }); - - it("does not spend the one registration on a scope without `slots`", async () => { - // The failure this guards: `registerMeter` is reached from three places, and - // the mount's settlement hands over the ROOT context, which need not carry - // `slots`. Marking the one-shot guard before the attempt meant a path that - // could not register still consumed the registration — the scoped inject then - // found the guard set and returned, and the pill never appeared anywhere. - // `?.` swallowed the no-op, so nothing warned. - const injected: string[] = []; - const child = { - slots: { - inject: (slot: string) => { - injected.push(slot); - return () => {}; - }, - register: () => () => {}, - }, - }; - const ctx = { - effect: (fn: () => unknown) => fn(), - // The scoped inject fires AFTER the mount has settled, with a scope that - // does carry `slots` — the ordering that used to lose the meter. - inject: (_names: string[], cb: (scope: unknown) => void) => { - setTimeout(() => { - cb(child); - }, 5); - return () => {}; - }, - locale: { register: () => () => {} }, - remote: { $mount: async () => () => {} }, - // The root has no `slots` at all. - }; - - apply(ctx as never); - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - expect(injected).toEqual(["conversation.input.right"]); - }); - - it("registers the meter from the mount alone when the scoped inject never fires", async () => { - // The case the ten-second timeout used to cover. `ctx.inject` is present but - // its services never appear, so the ONLY way the meter can show up is the - // mount settling — which is what the timeout used to stand in for. - const injected: string[] = []; - const ctx = { - effect: (fn: () => unknown) => fn(), - // Registered, never called: the Host has no model directory to hand over. - inject: () => () => {}, - locale: { register: () => () => {} }, - remote: { - // A mount that resolves on a later macrotask, so the settlement is - // genuinely asynchronous rather than already done. - $mount: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - return () => {}; - }, - }, - slots: { - inject: (slot: string) => { - injected.push(slot); - return () => {}; - }, - register: (_entry: Record) => () => {}, - }, - }; - - apply(ctx as never); - // Not yet: the mount has not settled. - expect(injected).toEqual([]); - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - expect(injected).toEqual(["conversation.input.right"]); - }); -}); diff --git a/test/stream-hook.test.ts b/test/stream-hook.test.ts index 9f58ed22..9e031a77 100644 --- a/test/stream-hook.test.ts +++ b/test/stream-hook.test.ts @@ -12,13 +12,22 @@ * * @module test/stream-hook.test */ +import { AsyncLocalStorage } from "node:async_hooks"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { projectNameFrom, recordDebug } from "./support/src.ts"; +import { + type ActiveTurnState, + createStreamHook, + getSessionUsage, + openCodeSessionIdFor, + projectNameFrom, + recordDebug, + resolveConfig, +} from "./support/src.ts"; import { collectUnknown, createMockStream } from "./test-helpers.ts"; // The debug sink is observed rather than written: a negative assertion over a @@ -103,6 +112,17 @@ describe("stream-hook · projectNameFrom names the repository", () => { expect(answer).not.toContain("/"); }); + it("answers the folder when the repository root's own name is blank", () => { + // A root whose name trims to nothing names nothing: the walk stops there + // and the folder itself is the attribution, never an empty header. + const outer = tree(); + const blankRoot = join(outer, " "); + mkdirSync(join(blankRoot, ".git"), { recursive: true }); + const sub = join(blankRoot, "inside"); + mkdirSync(sub); + expect(projectNameFrom(sub)).toBe("inside"); + }); + it("reports the root even when the marker sits beside a file", () => { // `.git` is a DIRECTORY, but a worktree checkout uses a `.git` FILE. Both // mean "repository root", and the walk only asks whether the entry exists. @@ -123,16 +143,12 @@ describe("stream-hook · the pass-through guards", () => { const hookWith = async ( providers: string[], overrides: Record = {} - ) => { - const { createStreamHook, resolveConfig } = - await import("./support/src.ts"); - const { AsyncLocalStorage } = await import("node:async_hooks"); - return createStreamHook( + ) => + createStreamHook( { logger: { info: () => {} } } as never, resolveConfig({ providers, usageEnabled: false, ...overrides } as never), new AsyncLocalStorage() ); - }; it("hands through anything that is not an options object", async () => { const hook = await hookWith(["opencode"]); @@ -184,11 +200,8 @@ describe("stream-hook · the debug gate", () => { debug: boolean, file: string | undefined, info: (...args: unknown[]) => void - ) => { - const { createStreamHook, resolveConfig } = - await import("./support/src.ts"); - const { AsyncLocalStorage } = await import("node:async_hooks"); - return createStreamHook( + ) => + createStreamHook( { logger: { info } } as never, resolveConfig({ debug, @@ -198,7 +211,6 @@ describe("stream-hook · the debug gate", () => { } as never), new AsyncLocalStorage() ); - }; /** Drive one turn through the hook and drain it. */ const turn = async ( @@ -247,3 +259,139 @@ describe("stream-hook · the debug gate", () => { expect(info.mock.calls[0]).toContain("x-opencode-session"); }); }); + +describe("stream-hook · what a turn carries", () => { + /** + * The hook driven with a test-owned store, so what the turn hands the fetch + * patch is read where the fetch patch reads it: inside the stream. + */ + const als = new AsyncLocalStorage(); + + const hookOn = ( + ctx: Record = {}, + overrides: Record = {} + ) => + createStreamHook( + { logger: { info: () => {} }, ...ctx } as never, + resolveConfig({ + providers: ["opencode"], + usageEnabled: false, + ...overrides, + } as never), + als + ); + + /** + * A downstream that yields the store it ran under, once. + * + * @yields {ActiveTurnState | undefined} the turn store. + */ + const storeReader = async function* storeReader() { + yield als.getStore(); + }; + + /** A Host session service answering for one id. */ + const sessionsFor = (id: string, header: Record) => ({ + get: (name: string) => + name === "sessions" + ? { get: (asked: string) => (asked === id ? { header } : undefined) } + : undefined, + }); + + const storeOf = async (stream: unknown): Promise => { + const [store] = await collectUnknown(stream as AsyncIterable); + return store as ActiveTurnState; + }; + + beforeEach(() => { + vi.mocked(recordDebug).mockClear(); + }); + + it("a numeric sessionId and a numeric parentSessionId are stringified before derivation", async () => { + const store = await storeOf( + hookOn()( + { model: "m", parentSessionId: 9, provider: "opencode", sessionId: 7 }, + storeReader + ) + ); + expect(store.value).toBe(openCodeSessionIdFor("7")); + expect(store.parentValue).toBe(openCodeSessionIdFor("9")); + }); + + it("a session whose cwd is the filesystem root carries no project", async () => { + const store = await storeOf( + hookOn(sessionsFor("rooted", { cwd: "/" }))( + { model: "m", provider: "opencode", sessionId: "rooted" }, + storeReader + ) + ); + expect(store.value).toBe(openCodeSessionIdFor("rooted")); + expect(store.project).toBeUndefined(); + }); + + it("the debug entry carries the parent's derived id and the project", async () => { + const debugFile = join(tmpdir(), "dsh-stream-debug", "parent.jsonl"); + await storeOf( + hookOn(sessionsFor("child", { cwd: "/home/you/projects/my-app" }), { + debug: true, + debugFile, + })( + { + model: "m", + parentSessionId: "parent", + provider: "opencode", + sessionId: "child", + }, + storeReader + ) + ); + expect(recordDebug).toHaveBeenCalledTimes(1); + const entry = vi.mocked(recordDebug).mock.calls[0]?.[2]; + expect(entry).toMatchObject({ + parentSession: openCodeSessionIdFor("parent"), + project: "my-app", + session: "child", + value: openCodeSessionIdFor("child"), + }); + }); + + it("a model that is not a string is recorded as no model at all", async () => { + const [, store] = await collectUnknown( + hookOn()( + { model: 42, provider: "opencode", sessionId: "no-model" }, + async function* usageThenStore() { + yield { type: "usage", usage: { inputTokens: 3, outputTokens: 4 } }; + yield als.getStore(); + } + ) as AsyncIterable + ); + expect((store as ActiveTurnState).model).toBeUndefined(); + const usage = getSessionUsage("no-model"); + expect(usage?.turns).toBe(1); + // "unknown" is the accumulator's word for no model: never an active one. + expect(usage?.activeModel).toBeUndefined(); + expect(usage?.modelsUsed).toEqual([]); + }); + + it("a usage chunk carrying only cacheReadTokens is recorded with the other counters absent", async () => { + await collectUnknown( + hookOn()( + { model: "m", provider: "opencode", sessionId: "cache-only" }, + async function* cacheOnly() { + yield { + type: "usage", + usage: { cacheReadTokens: 12, inputTokens: "x" }, + }; + } + ) as AsyncIterable + ); + const usage = getSessionUsage("cache-only"); + expect(usage).toMatchObject({ + cacheReadTokens: 12, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + turns: 1, + }); + }); +}); diff --git a/test/support/card-tree.ts b/test/support/card-tree.ts new file mode 100644 index 00000000..9a8a427b --- /dev/null +++ b/test/support/card-tree.ts @@ -0,0 +1,27 @@ +/** + * The settings card's controls, read off a rendered element tree. + * + * Shared by the card's rendering tests: which component each register `kind` + * renders as, and the card's controls in document order. + * + * @module test/support/card-tree + */ +import { + elementName, + findAllWhere, + type TestElement, +} from "../test-helpers.ts"; + +/** Every control component the card can render, keyed by the register's `kind`. */ +export const COMPONENT_BY_KIND = { + boolean: "SettingsBooleanField", + select: "SettingsChoiceField", +} as const; + +const CONTROL_TYPES: ReadonlySet = new Set( + Object.values(COMPONENT_BY_KIND) +); + +/** The card's controls, in document order. */ +export const controlsInOrder = (tree: unknown): TestElement[] => + findAllWhere(tree, (element) => CONTROL_TYPES.has(elementName(element.type))); diff --git a/test/support/catalog-fixture.ts b/test/support/catalog-fixture.ts index d262f709..442b1396 100644 --- a/test/support/catalog-fixture.ts +++ b/test/support/catalog-fixture.ts @@ -16,7 +16,78 @@ * * @module test/support/catalog-fixture */ -import { refreshCatalog } from "./src.ts"; +import { + type CatalogModelSpec, + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, + protocolForSdk, + refreshCatalog, +} from "./src.ts"; + +/** The two plane routes the shims describe. */ +export type CatalogRoute = "opencode" | "opencode-go"; + +/** The shipped shim for one plane route. */ +export const shimFor = (route: CatalogRoute): readonly CatalogModelSpec[] => + route === "opencode" ? OPENCODE_ZEN_CATALOG : OPENCODE_GO_CATALOG; + +/** + * The first shim row on `route` whose SDK speaks `protocol` — or, with no + * `protocol`, the first row naming no SDK at all — picked by shape + * so no case names a vendor id a catalog refresh can retire. Answers + * `undefined` when the plane has none; a caller that needs one says so by + * name (see {@link catalogRow}). + * + * @param route - the plane route. + * @param protocol - the wire protocol; omitted for "no SDK of its own". + * @returns the row, or `undefined`. + */ +export const findCatalogRow = ( + route: CatalogRoute, + protocol?: string +): CatalogModelSpec | undefined => + shimFor(route).find((row) => + protocol === undefined + ? row.provider_npm === undefined + : row.provider_npm !== undefined && + protocolForSdk(row.provider_npm) === protocol + ); + +/** + * {@link findCatalogRow}, failing by name when the plane has no such row, so + * a case that cannot find its fixture fails rather than passing vacuously. + */ +export const catalogRow = ( + route: CatalogRoute, + protocol?: string +): CatalogModelSpec => { + const row = findCatalogRow(route, protocol); + if (row === undefined) { + throw new Error( + `the ${route} shim carries no row ${protocol === undefined ? "without an SDK" : `on ${protocol}`}` + ); + } + return row; +}; + +/** + * A free Zen id, read off the shim rather than named, so a refresh that + * retires one free model cannot turn a case about free models vacuous. + * + * @param excluding - a substring the id must not contain (a marker under test). + * @returns the id. + */ +export const freeZenModel = (excluding?: string): string => { + const spec = OPENCODE_ZEN_CATALOG.find( + (row) => + row.is_free === true && + (excluding === undefined || !row.id.includes(excluding)) + ); + if (spec === undefined) { + throw new Error("the Zen shim carries no free model"); + } + return spec.id; +}; /** Free on Zen, paid on Go. */ export const ZEN_FREE_PROBE = "plane-probe"; diff --git a/test/support/composition.ts b/test/support/composition.ts new file mode 100644 index 00000000..ba606d4a --- /dev/null +++ b/test/support/composition.ts @@ -0,0 +1,175 @@ +/** + * The plugin mounted on a genuine cordis `Context`, the way the Loader does. + * + * A unit test that means "the plugin, applied" mounts it here rather than + * handing `apply` an object literal: `ctx.plugin({ apply, Config, inject, + * name }, config)` is the module's own exports and the row's config, which is + * what the Loader hands cordis — so the schema resolves the config (and an + * invalid one fails the fiber), the fiber carries the plugin's name, cordis + * keys the runtime on the module's own `apply` (repeated activations on one + * root are fibers of ONE runtime, as the Loader's are), and + * cordis itself decides when each part runs: the root at once, the `llm` + * scope once that service exists. `fiber.dispose()` runs the plugin's own + * effects in reverse, which is the only teardown a deployment has. A stand-in + * whose `effect` does nothing never runs a disposer, so a restore bug is + * invisible to it by construction. + * + * {@link degradedHost} is the one stand-in for the plugin's COMPOSITION, for + * the diagnostics a real `Context` cannot reach, because a real one registers + * what it is asked to and always has `plugin`: (1) a `plugin` that registers + * nothing, with and without `get`, which drives the three unreachable-service + * diagnostics; (2) a composition with no `plugin`, or with neither `plugin` + * nor `provide`. Even there `effect` runs + * its callback and keeps the disposer, so nothing it installs outlives the + * case. (The route claim's clause tests keep a stand-in host of their own, + * `route-claim-stand-in.ts`, for fiber states a real host cannot be put in on + * demand; that is a module under test, not the composition.) + * + * @module test/support/composition + */ +import { Context, type Fiber } from "@deepseek-ai/cordis"; + +import { FIBER_STATE, logLines, until } from "./cordis-wait.ts"; +import { apply, Config, type CordisContext, inject, name } from "./src.ts"; + +export { until }; + +/** What {@link mountPlugin} takes. */ +export interface MountOptions { + /** A context to mount on again (a second activation on one root). */ + readonly ctx?: Context; + /** + * The plugin's row config, resolved through its schema as the Loader's is; + * omitted, the shipped defaults apply. + */ + readonly config?: Record; + /** An `llm` service to provide before the mount; omitted, none exists. */ + readonly llm?: object; +} + +/** A mounted plugin. */ +export interface MountedPlugin { + readonly ctx: Context; + readonly fiber: Fiber; + /** + * Whether the fiber is active once awaited (it waits for nothing: `inject` + * is empty). Read off the fiber, so the mount passes the module's own + * `apply` and repeated activations share one cordis runtime, as the + * Loader's do. + */ + readonly activated: () => boolean; + /** Every line any logger wrote since the mount began. */ + readonly lines: () => string[]; +} + +/** Fibers mounted and not yet disposed, for {@link disposeMounted}. */ +const live = new Set(); + +/** + * Mount the plugin the way the Loader does. + * + * The module's exports — `apply`, `Config`, `inject`, `name` — with the row + * config as the second argument. `inject` is the plugin's OWN declaration — + * empty, so the root activates before `llm` exists and everything that needs + * `llm` waits in a child scope — and cordis itself decides when each part + * runs. The fiber is awaited, which + * is the plugin's `apply` returning (the route claim's mount); anything that + * settles later is waited for with {@link until}, never with a fixed sleep. + * + * @param options - the context, config and `llm` service. + * @returns the mounted plugin; {@link disposeMounted} disposes it. + */ +export const mountPlugin = async ( + options: MountOptions = {} +): Promise => { + const ctx = options.ctx ?? new Context(); + const log = logLines(ctx); + if (options.llm !== undefined) { + ctx.provide("llm", options.llm as never); + } + const fiber = ctx.plugin( + { apply, Config, inject, name } as never, + (options.config ?? {}) as never + ); + live.add(fiber); + await fiber.await(); + return { + activated: () => { + // cordis's `FiberState` is a const enum; read it as the number it is. + const state: number = fiber.state; + return state === FIBER_STATE.ACTIVE; + }, + ctx, + fiber, + lines: () => log.lines(), + }; +}; + +/** + * Dispose every fiber {@link mountPlugin} mounted, latest first, so the + * patched `fetch` never leaks into the next case. Latest first is the order + * that unwinds nested patches cleanly; the other order is P6's open defect + * (an earlier activation's patch outlives two overlapping ones). + */ +export const disposeMounted = async (): Promise => { + const fibers = [...live].toReversed(); + live.clear(); + for (const fiber of fibers) { + await fiber.dispose(); + } +}; + +/** What {@link degradedHost} takes: the parts a degraded composition has. */ +export interface DegradedOptions { + readonly get?: (name: string) => unknown; + readonly plugin?: (...args: unknown[]) => unknown; + readonly provide?: (name: string, value: unknown) => void; + readonly logger: { + readonly info?: (...args: unknown[]) => void; + readonly warn?: (...args: unknown[]) => void; + }; +} + +/** A degraded composition, and the disposers its effects returned. */ +export interface DegradedHost { + readonly ctx: CordisContext; + /** Run every collected disposer, latest first. */ + readonly dispose: () => void; +} + +/** + * A composition a real `Context` cannot stand for: a `plugin` that registers + * nothing (with or without `get`), or one missing `plugin`, or `provide` too. + * Its `effect` runs the callback and records the disposer, as + * cordis does, so the fetch patch it installs is restored by `dispose()`. + * + * @param options - the parts the composition has. + * @returns the host and its teardown. + */ +export const degradedHost = (options: DegradedOptions): DegradedHost => { + const disposers: (() => void)[] = []; + const ctx = { + effect: (callback: () => unknown) => { + const disposer = callback(); + if (typeof disposer === "function") { + disposers.push(disposer as () => void); + } + }, + ...(options.get === undefined ? {} : { get: options.get }), + llm: {}, + logger: options.logger, + on: () => { + // a degraded composition attaches no stream hook anyone drives + }, + ...(options.plugin === undefined ? {} : { plugin: options.plugin }), + ...(options.provide === undefined ? {} : { provide: options.provide }), + } as unknown as CordisContext; + return { + ctx, + dispose: () => { + for (const disposer of disposers.splice(0).toReversed()) { + disposer(); + } + }, + }; +}; diff --git a/test/support/cordis-wait.ts b/test/support/cordis-wait.ts new file mode 100644 index 00000000..3f972a13 --- /dev/null +++ b/test/support/cordis-wait.ts @@ -0,0 +1,87 @@ +/** + * The two context-only helpers every real-context test shares: a deadline + * wait ({@link until}) and a capture of every logger line ({@link logLines}). + * + * They need nothing but a cordis `Context`, so they live apart from the + * real-host harness (`host.ts`, which re-exports them): a unit file that + * mounts the plugin on a bare `Context` (`composition.ts`) loads no host + * package through them. + * + * @module test/support/cordis-wait + */ +import type { Context } from "@deepseek-ai/cordis"; + +/** + * cordis `FiberState`: the three states a booted row settles in. cordis + * declares it a `const enum`, which isolated transpilation cannot import at + * runtime, so the values are mirrored here (as `src/host/route-claim.ts` + * mirrors `ACTIVE`). + */ +export const FIBER_STATE = { ACTIVE: 2, DISPOSED: 4, FAILED: 3 } as const; + +/** + * Poll until `predicate` holds, or fail by name: the harness's one wait, with a + * deadline rather than a fixed sleep. + * + * @param predicate - the condition, sync or async. + * @param what - what is awaited, named in the failure. + * @param deadlineMs - how long to wait before failing. + */ +export const until = async ( + predicate: () => boolean | Promise, + what: string, + deadlineMs = 10_000 +): Promise => { + const end = Date.now() + deadlineMs; + while (!(await predicate())) { + if (Date.now() > end) { + throw new Error(`never happened: ${what}`); + } + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } +}; + +/** + * Every line any logger writes from now on, at every level. + * + * cordis's own exporter buffers only up to `info` (its default threshold, so + * a `warn` is never kept), and tier B adds no console exporter: nothing a + * plugin logs reaches the console or the default buffer reliably. This + * exporter takes every level, so an assertion about what was logged reads + * what was actually logged. A case should log one line of its own through it + * to prove the input is read. + * + * @param root - the host's root context. + * @returns the lines (` : `) and the exporter's removal. + */ +export const logLines = ( + root: Context +): { readonly lines: () => string[]; readonly dispose: () => void } => { + const lines: string[] = []; + const logger = root.logger as unknown as { + exporter: (exporter: { + levels: { default: number }; + export: (message: { + type: string; + name: string; + args: unknown[]; + }) => void; + }) => () => void; + }; + const off = logger.exporter({ + export: (message) => { + lines.push( + `${message.type} ${message.name}: ${message.args.map(String).join(" ")}` + ); + }, + levels: { default: 3 }, + }); + return { + dispose: () => { + off(); + }, + lines: () => [...lines], + }; +}; diff --git a/test/support/e2e-env.ts b/test/support/e2e-env.ts new file mode 100644 index 00000000..459facf2 --- /dev/null +++ b/test/support/e2e-env.ts @@ -0,0 +1,120 @@ +/** + * The live suites' environment, read in one place. + * + * Every switch the end-to-end files obey — whether the live suite runs, which + * key tier is armed, a mirror's base URLs, where the record goes, the CI run + * that produced it, and the published-host cell — is read here and nowhere + * else, so what arms a key is decided by one function a reader can find, and + * a unit test can call with an environment of its own. `keyed-probes.ts` + * stays pure: it decides from the values this module reads. + * + * An `OPENCODE_E2E_KEYED` the tier reader does not know fails each e2e file at + * import (each calls {@link e2e} at module level), which is the run failing + * rather than silently arming or disarming a suite. Loading this module reads + * nothing, so the unit file that drives the reader loads it safely. + * + * @module test/support/e2e-env + */ +import { armedKey, type KeyedTier, keyedTierOf } from "./keyed-probes.ts"; + +type Environment = Readonly>; + +/** What the live suites read from the environment. */ +export interface E2eEnvironment { + /** `OPENCODE_E2E=1`: the live suite runs at all. */ + readonly live: boolean; + /** `OPENCODE_E2E_KEYED`: what may spend a key. */ + readonly tier: KeyedTier; + /** Either plane's base is overridden: the suite points at a mirror. */ + readonly mirrored: boolean; + /** A base to use instead of the vendor's, per plane, when one is set. */ + readonly baseOverride: { readonly go?: string; readonly zen?: string }; + /** Where the dated record tables go; empty means nowhere. */ + readonly recordDir: string; + /** The CI run a record links to, when there is one. */ + readonly run: { + readonly repository: string | undefined; + readonly runId: string | undefined; + readonly server: string | undefined; + }; + /** The published-host binary e2e (`DSH_HOST_E2E=1`) and its inputs. */ + readonly hostBinary: { + readonly enabled: boolean; + readonly prefix: string; + readonly cell: string; + readonly probeCopy: string; + /** The `PATH` the binary is started with. */ + readonly path: string; + }; + /** + * The key a suite needing `need` may spend on `plane`, or `undefined` — + * which every keyed suite reads as "skip". + */ + readonly keyFor: ( + plane: "opencode" | "opencode-go", + need: "full" | "probes" + ) => string | undefined; +} + +/** A value, or `undefined` when it is unset or blank. */ +const nonEmpty = (value: string | undefined): string | undefined => + value === undefined || value.length === 0 ? undefined : value; + +/** + * Read the live suites' environment. + * + * @param env - the environment to read; the process's own by default. + * @returns what the live suites obey. + */ +export const readE2eEnvironment = ( + env: Environment = process.env +): E2eEnvironment => { + const tier = keyedTierOf(env.OPENCODE_E2E_KEYED); + const zen = nonEmpty(env.OPENCODE_ZEN_BASE_URL); + const go = nonEmpty(env.OPENCODE_GO_BASE_URL); + return { + baseOverride: { + ...(go === undefined ? {} : { go }), + ...(zen === undefined ? {} : { zen }), + }, + hostBinary: { + cell: env.DSH_HOST_PI_CELL ?? "pinned", + enabled: env.DSH_HOST_E2E === "1", + path: env.PATH ?? "", + prefix: env.DSH_HOST_PREFIX ?? "", + probeCopy: env.DSH_HOST_PROBE_COPY ?? "", + }, + keyFor: (plane, need) => + armedKey( + tier, + need, + plane === "opencode" ? env.OPENCODE_API_KEY : env.OPENCODE_GO_API_KEY + ), + live: env.OPENCODE_E2E === "1", + mirrored: zen !== undefined || go !== undefined, + recordDir: env.OPENCODE_E2E_RECORD_DIR ?? "", + run: { + repository: env.GITHUB_REPOSITORY, + runId: env.GITHUB_RUN_ID, + server: env.GITHUB_SERVER_URL, + }, + tier, + }; +}; + +let processEnvironment: E2eEnvironment | undefined; + +/** + * The process's own live-suite environment, read on first call and kept. + * + * A function rather than a constant evaluated at import, so loading this + * module reads nothing: the unit file that drives {@link readE2eEnvironment} + * with environments of its own (`test/e2e-env.test.ts`) does not fail at + * collection in a shell exporting an unknown `OPENCODE_E2E_KEYED`. Each e2e + * file calls it at module level, so an unknown tier still fails the e2e suite + * at import. + * + * @returns the environment of the running process. + */ +export const e2e = (): E2eEnvironment => + (processEnvironment ??= readE2eEnvironment()); diff --git a/test/support/host-planes.ts b/test/support/host-planes.ts new file mode 100644 index 00000000..a1a5785e --- /dev/null +++ b/test/support/host-planes.ts @@ -0,0 +1,178 @@ +/** + * Shared by the `host-planes*` files: the plugin owning the plane routes, on + * the real host. The wire recorder, the edits a settings surface derives, the + * registry and profile readers, and the tier-B boot every case starts from. + * + * `wire` is the recorder the last {@link offline} installed; a case reads it, + * and {@link disposeBooted} disposes every host {@link bootPlanes} booted. + * + * @module test/support/host-planes + */ +import { vi } from "vitest"; + +import { + adaptersOf, + type ApplyOrder, + bootTierB, + characterizedProviders, + drainStream, + fakeCredentials, + type HostLlm, + logLines, + piAiRestarts, + type PluginRow, + recordingFetch, + type RecordingFetch, + type TierB, +} from "./host.ts"; + +/** The recorder the last {@link offline} installed. */ +// oxlint-disable-next-line import/no-mutable-exports -- a live binding: each case reads the recorder its own `offline()` installed. +export let wire: RecordingFetch; + +/** Hosts {@link bootPlanes} booted and the case has not disposed. */ +const booted: TierB[] = []; + +/** Dispose every host {@link bootPlanes} booted; each file's `afterEach` runs it. */ +export const disposeBooted = async (): Promise => { + for (const tier of booted.splice(0)) { + await tier.dispose(); + } +}; + +/** A fiber's state, read as a plain value. */ +export const stateOf = ( + fiber: { readonly state: unknown } | undefined +): unknown => fiber?.state; + +/** Stub `fetch` with a fresh recorder (before mounting the plugin). */ +export const offline = ( + respond?: Parameters[0] +): void => { + wire = recordingFetch(respond); + vi.stubGlobal("fetch", wire.fetch); +}; + +export type Providers = Record>; + +/** An edit of one provider entry, the way a settings surface derives one. */ +export const editProvider = + (route: string, patch: Record) => + (current: Record): Record => { + const providers = { ...(current.providers as Providers) }; + providers[route] = { ...providers[route], ...patch }; + return { ...current, providers }; + }; + +/** An edit removing provider entries, the way a settings surface derives one. */ +export const removeProviders = + (...routes: string[]) => + (current: Record): Record => ({ + ...current, + providers: Object.fromEntries( + Object.entries(current.providers as Providers).filter( + ([route]) => !routes.includes(route) + ) + ), + }); + +/** A config-editor mutation appending `id` to a plane's models. */ +export const appendTo = + (plane: string, id: string) => (current: Record) => { + const entry = (current.providers as Providers)[plane] as + | { models?: { id: string }[] } + | undefined; + return editProvider(plane, { + models: [...(entry?.models ?? []), { id }], + })(current); + }; + +export { routeIds } from "./host.ts"; + +/** The plane adapter's resolved profile for a route (its private `config`). */ +export const planeProfile = ( + llm: HostLlm, + route: string +): Record => { + const adapter = adaptersOf(llm).get(route)?.adapter as unknown as { + config: { profiles: () => ReadonlyMap> }; + }; + const profile = adapter.config.profiles().get(route); + if (profile === undefined) { + throw new Error(`no plane profile for ${route}`); + } + return profile; +}; + +/** One turn's gateway requests, the catalog read excluded. */ +export const turn = async (llm: HostLlm, route: string, model: string) => { + const before = wire.requests.length; + const drained = await drainStream(llm, route, model); + return { + drained, + requests: wire.requests + .slice(before) + .filter( + (request) => !request.url.startsWith("https://models.opencode.ai/") + ), + }; +}; + +export const callConfigOf = async ( + llm: HostLlm, + route: string, + model: string +): Promise => { + try { + await llm.resolveCallConfig({ model, provider: route } as never); + return "ok"; + } catch (error) { + return String((error as { code?: unknown }).code); + } +}; + +/** Boot tier B with the characterization profile, counting pi-ai's restarts. */ +export const bootPlanes = async ( + options: { + readonly order?: ApplyOrder; + readonly pluginRow?: PluginRow; + readonly providers?: Providers; + } = {} +): Promise<{ + tier: TierB; + restarts: () => number; + logged: () => string[]; +}> => { + offline(); + let restarts: ReturnType | undefined; + let log: ReturnType | undefined; + const tier = await bootTierB({ + credentials: fakeCredentials({ + refs: { OPENCODE_API_KEY: "zen-k", OPENCODE_GO_API_KEY: "go-k" }, + }), + order: options.order ?? "plugin-first", + ...(options.pluginRow === undefined + ? {} + : { pluginRow: options.pluginRow }), + prepare: (root) => { + restarts = piAiRestarts(root); + log = logLines(root); + }, + providers: options.providers ?? characterizedProviders(), + }); + booted.push(tier); + return { + logged: () => log?.lines() ?? [], + restarts: () => restarts?.count() ?? 0, + tier, + }; +}; + +/** Let every queued microtask and timer of a settled host run. */ +export const settle = async (): Promise => { + for (let index = 0; index < 10; index += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } +}; diff --git a/test/support/host.ts b/test/support/host.ts index 520d2ec1..74b52748 100644 --- a/test/support/host.ts +++ b/test/support/host.ts @@ -109,6 +109,8 @@ import { opencodeProvider } from "@earendil-works/pi-ai/providers/opencode"; import { opencodeGoProvider } from "@earendil-works/pi-ai/providers/opencode-go"; import { withOpenCodeSessionHeader } from "@earendil-works/pi-ai/providers/opencode-headers"; +import { urlOf } from "../test-helpers.ts"; +import { FIBER_STATE } from "./cordis-wait.ts"; import { apply, Config, @@ -120,12 +122,21 @@ import { replaceHostModuleSource, } from "./src.ts"; +export { FIBER_STATE, logLines, until } from "./cordis-wait.ts"; + /** The package name the plugin looks pi-ai up by (`PI_AI_PACKAGE` in `src/host/host-modules.ts`). */ export const PI_AI_PACKAGE = "@deepseek-ai/dsh-llm-pi-ai"; /** The `llm` service as the tests read it, private registry included. */ export type HostLlm = Context["llm"]; +/** The route ids an `llm` service lists, sorted. */ +export const routeIds = (llm: HostLlm): string[] => + llm + .listProviders() + .map((provider) => provider.id) + .toSorted(); + /** One registry row, as `LlmRuntime`'s private `adapters` map holds it. */ export interface RegistryRow { readonly adapter: { @@ -333,13 +344,6 @@ export interface RecordingFetch { readonly requests: RecordedRequest[]; } -const urlOf = (input: RequestInfo | URL): string => { - if (typeof input === "string") { - return input; - } - return input instanceof URL ? input.href : input.url; -}; - const recordHeaders = ( input: RequestInfo | URL, init: RequestInit | undefined @@ -586,7 +590,7 @@ const B = "BASELINE"; export const S0_TODAY = { /** * `listModels` before any turn, with the keys stored: every declared id - * (D21). The keyless boot is `test/host-planes.test.ts`'s declared-listing + * (D21). The keyless boot is `test/host-planes-credentials.test.ts`'s declared-listing * case. */ listingBeforeTurn: CHARACTERIZED_MODELS, @@ -923,6 +927,10 @@ export const waitForPlaneOwner = async ( } }; +/** The ids of a listing, in order. */ +export const modelIds = (models: readonly { id: string }[]): string[] => + models.map((model) => model.id); + /** The adapter class serving a route, or `undefined`. */ export const ownerOf = (llm: HostLlm, route: string): string | undefined => adaptersOf(llm).get(route)?.adapter.constructor.name; @@ -958,49 +966,6 @@ export const piAiRestarts = ( }; }; -/** - * Every line any logger writes from now on, at every level. - * - * cordis's own exporter buffers only up to `info` (its default threshold, so - * a `warn` is never kept), and tier B adds no console exporter: nothing a - * plugin logs reaches the console or the default buffer reliably. This - * exporter takes every level, so an assertion about what was logged reads - * what was actually logged. A case should log one line of its own through it - * to prove the input is read. - * - * @param root - the host's root context. - * @returns the lines (` : `) and the exporter's removal. - */ -export const logLines = ( - root: Context -): { readonly lines: () => string[]; readonly dispose: () => void } => { - const lines: string[] = []; - const logger = root.logger as unknown as { - exporter: (exporter: { - levels: { default: number }; - export: (message: { - type: string; - name: string; - args: unknown[]; - }) => void; - }) => () => void; - }; - const off = logger.exporter({ - export: (message) => { - lines.push( - `${message.type} ${message.name}: ${message.args.map(String).join(" ")}` - ); - }, - levels: { default: 3 }, - }); - return { - dispose: () => { - off(); - }, - lines: () => [...lines], - }; -}; - /** * The host modules from this repository's own install, statically imported: * what the plugin reads through its loader on a real host, handed in through @@ -1257,7 +1222,7 @@ export const stubModuleLoader = ( export type ApplyOrder = "pi-ai-first" | "plugin-first"; /** The fiber states a row settles in: ACTIVE, FAILED, DISPOSED (cordis `FiberState`). */ -const SETTLED_STATES: ReadonlySet = new Set([2, 3, 4]); +const SETTLED_STATES: ReadonlySet = new Set(Object.values(FIBER_STATE)); /** {@link orderedModules}' answer: the modules, and the hook that arms the fallback release. */ interface OrderedModules { @@ -1283,17 +1248,23 @@ interface OrderedModules { * @param order - which applies first. * @returns the modules for {@link stubModuleLoader}, and the fallback hook. */ -const orderedModules = (order: ApplyOrder): OrderedModules => { +const orderedModules = ( + order: ApplyOrder, + modules: ReadonlyMap, + pluginPackage: string +): OrderedModules => { const { promise: released, resolve: release } = Promise.withResolvers(); const [first, second] = - order === "plugin-first" ? [name, PI_AI_PACKAGE] : [PI_AI_PACKAGE, name]; - const firstModule = HOST_MODULES.get(first) as { + order === "plugin-first" + ? [pluginPackage, PI_AI_PACKAGE] + : [PI_AI_PACKAGE, pluginPackage]; + const firstModule = modules.get(first) as { apply: (ctx: unknown, config: unknown) => unknown; }; const held = async (): Promise => { await released; - return HOST_MODULES.get(second); + return modules.get(second); }; const firstApply = (ctx: unknown, config: unknown): unknown => { try { @@ -1304,7 +1275,7 @@ const orderedModules = (order: ApplyOrder): OrderedModules => { }; return { modules: new Map([ - ...HOST_MODULES, + ...modules, [first, { ...firstModule, apply: firstApply }], [second, held()], ]), @@ -1323,23 +1294,35 @@ const orderedModules = (order: ApplyOrder): OrderedModules => { }; }; -/** The bundle rows, in the ids the real host's `dsh-base` patch names them. */ -const BUNDLE_ROWS = [ +/** The host's bundle rows, in the ids the real host's `dsh-base` patch names them. */ +const HOST_ROWS = [ { id: "llm", name: "@deepseek-ai/dsh-llm" }, { id: "llm-pi-ai", name: PI_AI_PACKAGE }, { id: "config-editor", name: "@deepseek-ai/dsh-config-editor" }, { id: "settings", name: "@deepseek-ai/dsh-settings" }, - { id: name, name }, ]; +/** + * This plugin's row as a profile files it: under its own id and package by + * default, or under another (the pre-rename ones a profile may still carry). + */ +export interface PluginRow { + /** The row id (default: the plugin's `name`). */ + readonly id?: string; + /** The package the loader imports (default: the plugin's `name`). */ + readonly name?: string; + /** A profile row config for the plugin; omitted, the shipped defaults apply. */ + readonly config?: Record; +} + /** What {@link bootTierB} takes. */ export interface TierBOptions { /** pi-ai's `providers` dict, written to the profile patch. */ readonly providers?: Record; /** Mount this plugin's row (default `true`); host-only contracts leave it out. */ readonly plugin?: boolean; - /** A profile row for this plugin's config; omitted, the shipped defaults apply. */ - readonly pluginConfig?: Record; + /** How the profile files this plugin's row, and its config. */ + readonly pluginRow?: PluginRow; /** The profile patch as raw YAML text, replacing the generated one (for `!!js`). */ readonly profilePatchYaml?: string; readonly credentials?: FakeCredentials; @@ -1399,7 +1382,8 @@ const blankAmbientKeys = (): (() => void) => { /** The profile patch the S0-style boots write, as JSON text (valid YAML). */ const profilePatchText = ( providers: Record | undefined, - pluginConfig: Record | undefined + pluginConfig: Record | undefined, + pluginId: string ): string => `${JSON.stringify( [ @@ -1408,7 +1392,7 @@ const profilePatchText = ( : [{ config: { providers }, id: "llm-pi-ai" }]), ...(pluginConfig === undefined ? [] - : [{ config: pluginConfig, id: name }]), + : [{ config: pluginConfig, id: pluginId }]), ], null, 2 @@ -1425,6 +1409,13 @@ export const bootTierB = async (options: TierBOptions = {}): Promise => { const credentials = options.credentials ?? fakeCredentials(); const launchEnvironment = options.launchEnvironment ?? fakeLaunchEnvironment(); + const pluginId = options.pluginRow?.id ?? name; + const pluginPackage = options.pluginRow?.name ?? name; + // A row filed under another package resolves to this plugin all the same. + const modules: ReadonlyMap = + pluginPackage === name + ? HOST_MODULES + : new Map([...HOST_MODULES, [pluginPackage, HOST_MODULES.get(name)]]); const home = realpathSync(mkdtempSync(join(tmpdir(), "dsh-opencode-host-"))); const restoreKeys = blankAmbientKeys(); let ctx: Context | undefined; @@ -1447,9 +1438,10 @@ export const bootTierB = async (options: TierBOptions = {}): Promise => { `${JSON.stringify( [ { - insert: BUNDLE_ROWS.filter( - (row) => options.plugin !== false || row.id !== name - ), + insert: + options.plugin === false + ? HOST_ROWS + : [...HOST_ROWS, { id: pluginId, name: pluginPackage }], }, ], null, @@ -1461,7 +1453,7 @@ export const bootTierB = async (options: TierBOptions = {}): Promise => { writeFileSync( patchPath, options.profilePatchYaml ?? - profilePatchText(options.providers, options.pluginConfig) + profilePatchText(options.providers, options.pluginRow?.config, pluginId) ); const profile: ProfileContext = { cwd: home, @@ -1485,7 +1477,11 @@ export const bootTierB = async (options: TierBOptions = {}): Promise => { if (options.plugin === false) { root.loader.internal = stubModuleLoader(HOST_MODULES); } else { - const ordered = orderedModules(options.order ?? "plugin-first"); + const ordered = orderedModules( + options.order ?? "plugin-first", + modules, + pluginPackage + ); ordered.watch(root); root.loader.internal = stubModuleLoader(ordered.modules); } @@ -1502,7 +1498,8 @@ export const bootTierB = async (options: TierBOptions = {}): Promise => { return found; }; const piEntry = entryFor("llm-pi-ai"); - const pluginEntry = options.plugin === false ? undefined : entryFor(name); + const pluginEntry = + options.plugin === false ? undefined : entryFor(pluginId); return { credentials, ctx: booted, diff --git a/test/support/keyed-probes.ts b/test/support/keyed-probes.ts index d49f2d60..5a154ec3 100644 --- a/test/support/keyed-probes.ts +++ b/test/support/keyed-probes.ts @@ -38,7 +38,7 @@ import { TODAY_DISPATCH, verdictOfAttempts, } from "./keyless-matrix.ts"; -import { OPENCODE_UA } from "./src.ts"; +import { OPENCODE_UA, openCodeSessionIdFor } from "./src.ts"; /** How much of the live suite may spend a key, from `OPENCODE_E2E_KEYED`. */ export const KEYED_TIERS = ["none", "probes", "full"] as const; @@ -136,12 +136,13 @@ export const keyedBody = ( }; /** - * The session id a keyed probe sends: the client's shape - * (`ses_<12 hex><14 base62>`) with its low twelve bits in the band the - * gateway admits (`0xFC0..0xFFE`; the mock in `test/mock/opencode-gateway.ts` - * enforces the same band), so a refusal cannot be the id's. + * The session id a keyed probe sends, derived the plugin's own way + * (`openCodeSessionIdFor`): the client's shape (`ses_<12 hex><14 base62>`) + * with its low twelve bits in the band the gateway admits (`0xFC0..0xFFE`; the + * mock in `test/mock/opencode-gateway.ts` enforces the same band) by + * construction, so a refusal cannot be the id's. */ -export const PROBE_SESSION_ID = "ses_000000000ffeKeyedProbe0001"; +export const PROBE_SESSION_ID = openCodeSessionIdFor("keyed-probe"); /** * The identity headers the plugin's fetch patch sets on an OpenCode plane diff --git a/test/support/loader-rows.ts b/test/support/loader-rows.ts new file mode 100644 index 00000000..9f150047 --- /dev/null +++ b/test/support/loader-rows.ts @@ -0,0 +1,93 @@ +/** + * Loader rows and credential services, shaped the way `go-discovery.ts` reads + * them, served on a real cordis `Context`. + * + * The rows are what a test means; the context they are served on is the one a + * deployment has. `loaderOfContext` asks `ctx.get("loader")` first and reads + * the `loader` property only as a fallback, which a real plugin context never + * takes (property access without `inject` throws), so each helper provides + * `loader` (and `credentials`) on `new Context()` and the cases drive the arm + * a real host takes. Shared by the `go-discovery*` files and the usage + * service's (`usage-fixture.ts`). Deliberately malformed shapes (`{ loader: + * null }`, a non-callable `entries`) stay literal in the files that pass them, + * as direct inputs to the reader. + * + * @module test/support/loader-rows + */ +import { Context } from "@deepseek-ai/cordis"; + +/** The Zen plane, which discovery rewrites onto the Go plane. */ +export const ZEN_BASE = "https://opencode.ai/zen/v1"; +/** The Go plane every rewrite targets. */ +export const GO_BASE = "https://opencode.ai/zen/go/v1"; +/** A request URL that classifies its own tier as Go. */ +export const GO_REQUEST = "https://opencode.ai/zen/go/v1/chat/completions"; +/** A request URL that classifies its own tier as Zen. */ +export const ZEN_REQUEST = "https://opencode.ai/zen/v1/messages"; + +/** + * A real cordis context whose loader serves `entries`. One context per + * service, since a cordis Service registers itself on the context it is built + * on. The array is read on every call, so a case may push a row later. + */ +export const contextWithEntries = (entries: readonly unknown[]): Context => { + const ctx = new Context(); + ctx.provide("loader" as never, { entries: () => entries } as never); + return ctx; +}; + +/** A context serving no credentials service: a headless composition. */ +export const composition = (entries: readonly unknown[] = []): Context => + contextWithEntries(entries); + +/** One `llm-pi-ai` provider-registry entry carrying a single route row. */ +export const registryEntry = ( + routeId: string, + row: Record +): unknown => ({ + options: { config: { providers: { [routeId]: row } } }, +}); + +/** A registry entry declaring several routes at once, whatever shape each is. */ +export const registry = (rows: Record): unknown => ({ + options: { config: { providers: rows } }, +}); + +/** A standalone entry (`id: opencode-go`) declaring its config inline. */ +export const standaloneEntry = (options: Record): unknown => ({ + options, +}); + +/** The context plus the references the credentials service was asked for. */ +export interface CredentialProbe { + /** Every `ref` the lookup passed to `resolve`, in call order. */ + asked: string[]; + context: Context; +} + +/** + * A context serving a credentials service over `store`, recording each lookup. + * + * The recording is the point: "which reference was asked for" is not observable + * from the returned key alone whenever two candidates resolve to the same + * value, and it is the only way to prove the Zen reference is never offered to + * the Go endpoint. + */ +export const withCredentials = ( + store: Record, + entries: readonly unknown[] = [] +): CredentialProbe => { + const asked: string[] = []; + const context = contextWithEntries(entries); + context.provide( + "credentials" as never, + { + resolve: (ref: string): Promise<{ value?: string }> => { + asked.push(ref); + const value = store[ref]; + return Promise.resolve(value === undefined ? {} : { value }); + }, + } as never + ); + return { asked, context }; +}; diff --git a/test/support/offline.ts b/test/support/offline.ts index 8b9070db..68f6a9a9 100644 --- a/test/support/offline.ts +++ b/test/support/offline.ts @@ -16,20 +16,14 @@ * * @module test/support/offline */ +import { urlOf } from "../test-helpers.ts"; + const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); /** The host a fetch input targets, or `""` when it is not an absolute URL. */ export const hostOf = (input: RequestInfo | URL): string => { - let url: string; - if (typeof input === "string") { - url = input; - } else if (input instanceof URL) { - url = input.href; - } else { - ({ url } = input); - } try { - return new URL(url).hostname; + return new URL(urlOf(input)).hostname; } catch { return ""; } diff --git a/test/support/route-claim-stand-in.ts b/test/support/route-claim-stand-in.ts new file mode 100644 index 00000000..63123ac1 --- /dev/null +++ b/test/support/route-claim-stand-in.ts @@ -0,0 +1,186 @@ +/** + * A stand-in host for the route claim's clauses (`src/host/route-claim.ts`). + * + * Kept on purpose: these model fiber states the real host cannot be put in on + * demand — a pi-ai fiber LOADING or FAILED, a plugin fiber whose `uid` is + * already `null`, a registry that refuses — and `host-planes` covers the real + * host. The stand-in records what the claim registered, and the pi-ai fiber is + * a plain object the claim reads through `ctx.registry`. + * + * @module test/support/route-claim-stand-in + */ +import { type Mock, vi } from "vitest"; + +import { hostModulesForTests } from "./host.ts"; +import { + type CordisContext, + type HostModuleSource, + replaceHostModuleSource, +} from "./src.ts"; + +let restore: (() => void) | undefined; + +export const flush = async (): Promise => { + for (let index = 0; index < 5; index += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } +}; + +export type Listener = (this: unknown, ...args: unknown[]) => unknown; + +export interface FakeFiber { + uid: number | null; + state: number; + runtime: { name: string }; + _config: { providers: Record }; + config: { providers: { get: () => Record } }; + ctx: { waterfall?: (...args: unknown[]) => unknown }; + entry?: unknown; + parent?: { fiber: { state: number } }; + await: () => Promise; + restart: () => Promise; +} + +export const zen = { models: [{ id: "deepseek-v4-pro" }] }; + +/** A pi-ai fiber that holds `providers` raw and resolves `resolved`. */ +export const piFiber = ( + providers?: Record, + resolved?: Record +): FakeFiber => ({ + _config: { providers: providers ?? { deepseek: {}, opencode: zen } }, + await: vi.fn(async () => { + // the stand-in fiber is settled already + }), + config: { providers: { get: () => resolved ?? { deepseek: {} } } }, + ctx: { + waterfall: ( + _fiber: unknown, + _name: unknown, + _raw: unknown, + inner: unknown + ) => (inner as () => unknown)(), + }, + entry: { options: { id: "llm-pi-ai" } }, + parent: { fiber: { state: 2 } }, + restart: vi.fn(async () => { + // a restart the stand-in records and does not perform + }), + runtime: { name: "llm-pi-ai" }, + state: 2, + uid: 1, +}); + +/** What {@link standIn} records, and the context it hands the claim. */ +export interface StandIn { + readonly cleanups: (() => unknown)[]; + readonly ctx: CordisContext; + readonly fiber: { uid: number | null }; + readonly handle: Mock & { replace: Mock }; + readonly info: Mock; + readonly listeners: Map; + readonly llm: Record & { + readonly registerAdapter: Mock< + (routes: readonly string[], adapter: unknown) => unknown + >; + }; + /** Run the `llm` inject callback again, as cordis does for a new scope. */ + readonly reinject: (scope: Record) => void; + readonly warn: Mock; +} + +/** A stand-in plugin context: records listeners, effects and registrations. */ +export const standIn = ( + options: { + fibers?: () => FakeFiber[]; + llm?: Record; + uid?: number | null; + } = {} +): StandIn => { + const listeners = new Map(); + const cleanups: (() => unknown)[] = []; + const warn = vi.fn(); + const info = vi.fn(); + const handle = Object.assign(vi.fn(), { replace: vi.fn() }); + const llm = { + listProviders: () => [{ id: "deepseek" }], + registerAdapter: vi.fn( + (_routes: readonly string[], _adapter: unknown) => handle + ), + ...options.llm, + }; + const fiber = { uid: options.uid === undefined ? 1 : options.uid }; + let injected: ((scope: unknown) => void) | undefined; + const effect = (fn: () => unknown) => { + const cleanup = fn(); + if (typeof cleanup === "function") { + cleanups.push(cleanup as () => unknown); + } + }; + const ctx = { + effect, + fiber, + get: (name: string) => (name === "llm" ? llm : undefined), + inject: (deps: string[], callback: (scope: unknown) => void) => { + injected = callback; + callback(deps.includes("llm") ? { effect, llm } : {}); + }, + logger: { info, warn }, + on: (event: string, listener: Listener) => { + listeners.set(event, listener); + }, + registry: { + values: () => [ + { fibers: options.fibers?.() ?? [], name: "llm-pi-ai" }, + { fibers: [], name: "other" }, + ], + }, + } as unknown as CordisContext; + /** Run the `llm` inject callback again, as cordis does for a new scope. */ + const reinject = (scope: Record): void => { + injected?.({ effect, ...scope }); + }; + return { + cleanups, + ctx, + fiber, + handle, + info, + listeners, + llm, + reinject, + warn, + }; +}; + +/** The model a registered adapter's profile serves for a route. */ +export const servedModel = ( + adapter: unknown, + route: string, + id: string +): Record | undefined => { + const { config: options } = adapter as { + config: { + profiles: () => ReadonlyMap; + }; + }; + const provider = options.profiles().get(route)?.piProvider as + | { getModels: () => Record[] } + | undefined; + return provider?.getModels().find((model) => model.id === id); +}; + +/** Serve the host's modules through the seam, until {@link restoreTestModules}. */ +export const useTestModules = ( + source: HostModuleSource = hostModulesForTests() +): void => { + restore = replaceHostModuleSource(source); +}; + +/** Put the default host-module source back; every case's `afterEach` runs it. */ +export const restoreTestModules = (): void => { + restore?.(); + restore = undefined; +}; diff --git a/test/support/src.ts b/test/support/src.ts index e66cba45..11e66e4e 100644 --- a/test/support/src.ts +++ b/test/support/src.ts @@ -170,6 +170,8 @@ export { // identity.ts export { inject, + LEGACY_NAME, + LEGACY_PKG, name, OPENCODE_PATCH_USER_AGENT, OPENCODE_USER_AGENT, @@ -225,6 +227,17 @@ export { opencodeRoots, } from "../../src/opencode-paths.ts"; +// opencode-store-contract.ts +export { + parseStoreInspection, + STORE_NAMESPACE, + storeRemote, +} from "../../src/opencode-store-contract.ts"; +export type { + OpencodeStoreAccount, + OpencodeStoreInspection, +} from "../../src/opencode-store-contract.ts"; + // opencode-store-service.ts export { OpencodeStoreService } from "../../src/opencode-store-service.ts"; @@ -473,11 +486,15 @@ export type { RetryOptions, RetryTimer } from "../../src/utils/retry.ts"; // utils/session-id.ts export { + deriveSessionId, isOpenCodeMessageId, isOpenCodeSessionId, PARENT_SESSION_ALT_HEADER, PARENT_SESSION_HEADER, + pinnedSessionId, SESSION_AFFINITY_HEADER, SESSION_HEADER, + SESSION_ID_HEADER, sessionIdForRequest, } from "../../src/utils/session-id.ts"; +export type { SessionDigest } from "../../src/utils/session-id.ts"; diff --git a/test/support/usage-fixture.ts b/test/support/usage-fixture.ts new file mode 100644 index 00000000..55bb1441 --- /dev/null +++ b/test/support/usage-fixture.ts @@ -0,0 +1,115 @@ +/** + * Fixtures for the `GoUsageService` files: the Go endpoint, a healthy payload, + * a capturing `fetch` stub, the typed failure a read raises, and services + * built on real cordis contexts — one context per service, since a cordis + * Service registers itself on the context it is built on. + * + * @module test/support/usage-fixture + */ +import { Context } from "@deepseek-ai/cordis"; +import { vi } from "vitest"; + +import { type Capture, urlOf } from "../test-helpers.ts"; +import { contextWithEntries, registryEntry } from "./loader-rows.ts"; +import { GoUsageService, isRecord } from "./src.ts"; + +/** The Go quota endpoint the composition uses unless something overrides it. */ +export const GO_ENDPOINT = "https://opencode.ai/zen/go/v1"; +/** The URL a read of {@link GO_ENDPOINT} must actually call. */ +export const GO_USAGE_URL = `${GO_ENDPOINT}/usage`; + +/** The wrapped `/usage` payload the gateway answers a healthy read with. */ +export const okBody = (): string => + JSON.stringify({ + usage: { + monthly: { + percent: 100, + resetsAt: "2026-10-09T13:53:58.000Z", + status: "rate-limited", + }, + rolling: { + percent: 15, + resetsAt: "2026-10-01T16:55:56.004Z", + status: "ok", + }, + weekly: { + percent: 42, + resetsAt: "2026-10-05T00:00:00.000Z", + status: "ok", + }, + }, + }); + +/** + * Point `globalThis.fetch` at a response factory and capture the request. + * + * A factory rather than one `Response`: a body can only be read once, and the + * identity cases read the same service twice. + */ +export const stubFetch = (respond: () => Response): Capture => { + const capture: Capture = { init: undefined, url: "" }; + vi.stubGlobal( + "fetch", + (input: RequestInfo | URL, init?: RequestInit): Promise => { + capture.url = urlOf(input); + capture.init = init; + return Promise.resolve(respond()); + } + ); + return capture; +}; + +/** The fields of one quota read failure the meter branches on. */ +export interface UsageFailure { + cause: unknown; + code: unknown; + details: Record; + message: unknown; +} + +/** + * The typed failure one read raised, proven by shape rather than cast. + * + * Everything the meter branches on lives in `code`/`details`, so asserting the + * whole record also asserts what is *absent* — a non-configuration failure must + * not carry `configured: false`, or the client hides a retryable meter. + */ +export const failureOf = async ( + read: Promise +): Promise => { + const caught: unknown = await read.catch((error: unknown) => error); + if (!isRecord(caught) || !isRecord(caught.details)) { + throw new Error( + `expected a RemoteError carrying details, got ${String(caught)}` + ); + } + return { + cause: caught.cause, + code: caught.code, + details: caught.details, + message: caught.message, + }; +}; + +/** One `llm-pi-ai` provider-registry entry, shaped the way discovery reads it. */ +export const providerEntry = (row: Record): unknown => + registryEntry("opencode-go", row); + +export { contextWithEntries }; + +/** A service pinned to one endpoint and one credential. */ +export const serviceWithKey = ( + key: string, + base: string = GO_ENDPOINT +): GoUsageService => + new GoUsageService(new Context(), { + baseURL: () => base, + resolveApiKey: () => Promise.resolve(key), + }); + +/** + * A service with no credential at all, resolved the way a cold profile does it: + * no `resolveApiKey` escape hatch, nothing captured, nothing in the environment. + */ +export const serviceWithoutKey = (base: string = GO_ENDPOINT): GoUsageService => + new GoUsageService(new Context(), { baseURL: () => base }); diff --git a/test/test-helpers.ts b/test/test-helpers.ts index abd62dab..9936c44a 100644 --- a/test/test-helpers.ts +++ b/test/test-helpers.ts @@ -26,7 +26,12 @@ export const createMockStoreStream = async function* createMockStoreStream( yield als.getStore()?.value; }; -export const isRecord = (value: unknown): value is Record => { +/** + * The oracle's own object guard. Not exported: a test takes `isRecord` from the + * door, and this file cannot, because the jsdom tests import it and must not + * load the host graph the door re-exports. + */ +const isRecord = (value: unknown): value is Record => { if (value === null || value === undefined) { return false; } @@ -62,6 +67,14 @@ export const toolNamesOf = (body: unknown): string[] | undefined => { return names; }; +/** The URL a `fetch` input names, whichever of its three shapes it is. */ +export const urlOf = (input: RequestInfo | URL): string => { + if (typeof input === "string") { + return input; + } + return input instanceof URL ? input.href : input.url; +}; + export const parseJsonBody = (body: unknown): unknown => { if (typeof body !== "string") { return undefined; @@ -141,13 +154,7 @@ export const createCaptureFetch = (text = "ok") => { input: RequestInfo | URL, init?: RequestInit ): Promise => { - if (typeof input === "string") { - capture.url = input; - } else if (input instanceof URL) { - capture.url = input.toString(); - } else { - capture.url = input.url; - } + capture.url = urlOf(input); capture.init = init; return Promise.resolve(new Response(text)); }; @@ -157,23 +164,6 @@ export const createCaptureFetch = (text = "ok") => { export const headerOf = (init: RequestInit | undefined, field: string) => new Headers(init?.headers).get(field); -/** No-op disposer: tests register remotes but never tear them down. */ -const disposeNoop = (): void => { - // intentionally empty — nothing to dispose in a test process -}; - -/** - * Minimal Cordis-shaped context for services that only need `reflect.provide` - * to register a remote. Cast is deliberate: the real context is host-owned and - * unavailable to the test process. - */ -export const createMockContext = () => - ({ - // Faithful to Cordis: `provide` returns the disposer that unregisters - // the contribution. - reflect: { provide: () => disposeNoop }, - }) as never; - /* ------------------------------------------------ React element tree helpers */ /** diff --git a/test/usage-service-credentials.test.ts b/test/usage-service-credentials.test.ts new file mode 100644 index 00000000..52c226bb --- /dev/null +++ b/test/usage-service-credentials.test.ts @@ -0,0 +1,166 @@ +/** + * `GoUsageService` — a read without a credential, and the read a silent meter + * depends on. + * + * @module test/usage-service-credentials.test + */ +import { Context } from "@deepseek-ai/cordis"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { withCredentials } from "./support/loader-rows.ts"; +import { + GoUsageService, + clearCapturedApiKeys, + clearSessionUsageStore, + recordCapturedApiKey, +} from "./support/src.ts"; +import { + contextWithEntries, + failureOf, + GO_ENDPOINT, + GO_USAGE_URL, + okBody, + stubFetch, +} from "./support/usage-fixture.ts"; +import { headerOf } from "./test-helpers.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENCODE_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + clearCapturedApiKeys(); + clearSessionUsageStore(); +}); + +describe("GoUsageService credential failures", () => { + it("reports an unresolvable credential as a configuration state", async () => { + // `configured: false` is the whole point: the client renders nothing at all + // instead of an unavailable meter the user cannot act on. + const missing = Object.assign(new Error("no Go key"), { + code: "MISSING_CREDENTIAL", + }); + const service = new GoUsageService(new Context(), { + baseURL: () => GO_ENDPOINT, + resolveApiKey: (): Promise => Promise.reject(missing), + }); + + const failure = await failureOf(service.read()); + expect(failure.code).toBe("opencode-go/usage-unavailable"); + expect(failure.message).toBe("OpenCode Go API key is not configured"); + expect(failure.details).toEqual({ + configured: false, + retainPrevious: false, + retryable: false, + }); + }); + + it("keeps any other resolution failure visible and retryable", async () => { + // A credentials service that throws is a fault, not a configuration: the + // previous reading stays on screen and the poll retries. `configured` must + // be absent, or the client would hide a meter that is merely broken. + for (const cause of [ + new Error("credentials service unreachable"), + Object.assign(new Error("vault sealed"), { code: "ENOLOCK" }), + ]) { + const service = new GoUsageService(new Context(), { + baseURL: () => GO_ENDPOINT, + resolveApiKey: (): Promise => Promise.reject(cause), + }); + const failure = await failureOf(service.read()); + expect(failure.message).toBe("Could not resolve OpenCode Go API key"); + expect(failure.details).toEqual({ + retainPrevious: false, + retryable: true, + }); + } + }); + + it("hands the configured keySource policy to credential resolution", async () => { + // The policy is the user's choice between a rotated live key and a pinned + // one; if the service ignored it, `request` setups would silently keep + // polling with a stale credential. + process.env.OPENCODE_GO_API_KEY = "sk-env-key"; + recordCapturedApiKey( + "sk-captured-key", + "opencode-go", + "https://opencode.ai/zen/go/v1/chat/completions" + ); + const pinned = stubFetch(() => new Response(okBody())); + await new GoUsageService(contextWithEntries([]), { + keySource: "configured", + }).read(); + expect(headerOf(pinned.init, "authorization")).toBe("Bearer sk-env-key"); + + const rotated = stubFetch(() => new Response(okBody())); + await new GoUsageService(contextWithEntries([]), { + keySource: "request", + }).read(); + expect(headerOf(rotated.init, "authorization")).toBe( + "Bearer sk-captured-key" + ); + }); +}); + +describe("usage service: the read that a silent meter depends on", () => { + /** + * The whole path a working composition takes: the loader's rows name the Go + * route, the route names the credential reference, the service resolves it and + * reads the endpoint. + * + * It exists because "the meter shows nothing" is not evidence about which link + * broke, and this asserts the links the SERVICE owns. A probe of the same path + * against a real-shaped row and a real key answered with real window figures, + * so a silent meter is a wiring question rather than a service one - and this + * keeps that true. + */ + const goRow = { + options: { + config: { + providers: { "opencode-go": { apiKeyEnv: "OPENCODE_GO_API_KEY" } }, + }, + name: "@deepseek-ai/dsh-llm-pi-ai", + }, + }; + + it("resolves its key and reads the endpoint", async () => { + const respond = vi.fn(() => + Response.json({ + usage: { + monthly: { + percent: 44, + resetsAt: "2026-11-01T00:00:00.000Z", + status: "ok", + }, + rolling: { + percent: 0, + resetsAt: "2026-10-12T07:20:40.000Z", + status: "ok", + }, + weekly: { + percent: 0, + resetsAt: "2026-10-19T00:00:00.000Z", + status: "ok", + }, + }, + }) + ); + const capture = stubFetch(respond); + const { asked, context } = withCredentials( + { OPENCODE_GO_API_KEY: "go-key" }, + [goRow] + ); + + const service = new GoUsageService(context, { keySource: "auto" }); + const usage = await service.read({ provider: "opencode-go" }); + + // The route's own reference was offered first (order is the policy), the + // endpoint was asked once with the Go key, and the figures came back. + expect(asked[0]).toBe("OPENCODE_GO_API_KEY"); + expect(respond).toHaveBeenCalledTimes(1); + expect(capture.url).toBe(GO_USAGE_URL); + expect(headerOf(capture.init, "authorization")).toBe("Bearer go-key"); + expect(usage.weekly.percent).toBe(0); + expect(usage.monthly.percent).toBe(44); + expect(usage.quotaUnavailable ?? false).toBe(false); + }); +}); diff --git a/test/usage-service-failures.test.ts b/test/usage-service-failures.test.ts new file mode 100644 index 00000000..110ea217 --- /dev/null +++ b/test/usage-service-failures.test.ts @@ -0,0 +1,242 @@ +/** + * `GoUsageService` — every typed failure a gateway answer becomes. + * + * @module test/usage-service-failures.test + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { clearCapturedApiKeys, clearSessionUsageStore } from "./support/src.ts"; +import { + failureOf, + GO_USAGE_URL, + okBody, + serviceWithKey, + stubFetch, +} from "./support/usage-fixture.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENCODE_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + clearCapturedApiKeys(); + clearSessionUsageStore(); +}); + +describe("GoUsageService gateway failures", () => { + it("turns a 403 EntitlementError into the Zen-overflow reading", async () => { + // The account overflowed onto Zen: Go refuses with `EntitlementError`, and + // with Zen configured the right answer is the zeroed reading rather than an + // outage. The account id carries over from the last healthy poll because it + // is still the same account. + process.env.OPENCODE_API_KEY = "oc_sk_zen-key"; + let calls = 0; + stubFetch(() => { + calls += 1; + return calls === 1 + ? new Response(okBody()) + : new Response( + '{"error":{"code":"EntitlementError","message":"no Go subscription"}}', + { status: 403 } + ); + }); + const service = serviceWithKey("sk-live-key"); + + const before = await service.read(); + const after = await service.read(); + expect(after.zenOverflow).toBe(true); + expect(after.source).toBe(before.source); + + // The zeros are a TYPE FLOOR — they exist only to satisfy the shape, and + // the flag is what stops them being drawn. Verified against the live API: + // the same key returns real figures moments after an EntitlementError, so + // asserting `percent === 0` here would pin a fabricated number as if it + // were a reading. + expect(after.quotaUnavailable).toBe(true); + expect(after.rolling.percent).toBe(0); + }); + + it("does NOT mark a healthy reading as quota-unavailable", async () => { + // Overflow being CONFIGURED is the normal case — a Zen key exists and Go + // still reports real windows. Only the unreadable reading carries the flag, + // or every Go user would lose their quota rows. + stubFetch(() => new Response(okBody())); + process.env.OPENCODE_API_KEY = "oc_sk_zen-key"; + const usage = await serviceWithKey("sk-live-key").read(); + expect(usage.quotaUnavailable).toBeUndefined(); + expect(usage.rolling.percent).toBeGreaterThan(0); + }); + + it("refuses an EntitlementError when there is no Zen credit to overflow into", async () => { + // Without Zen the account simply has no Go plan, and the meter must say so + // as an unconfigured state instead of drawing zeros forever. + stubFetch( + () => + new Response('{"error":{"code":"EntitlementError"}}', { status: 403 }) + ); + const failure = await failureOf(serviceWithKey("sk-live-key").read()); + expect(failure.message).toBe("OpenCode Go subscription required"); + expect(failure.details).toEqual({ + configured: false, + retainPrevious: false, + retryable: false, + }); + }); + + it("separates a transient HTTP status from a final one", async () => { + // 408/429/5xx are worth retrying and must keep the last reading on screen; + // 4xx means the request itself is wrong and retrying just hammers the + // gateway with the same answer. + const cases: [number, boolean][] = [ + [408, true], + [429, true], + [500, true], + [503, true], + [400, false], + [404, false], + ]; + for (const [status, transient] of cases) { + stubFetch(() => new Response("upstream said no", { status })); + const failure = await failureOf(serviceWithKey("sk-live-key").read()); + expect(failure.message).toBe( + `OpenCode Go usage unavailable (HTTP ${status})` + ); + expect(failure.details.retryable).toBe(transient); + expect(failure.details.retainPrevious).toBe(transient); + expect(typeof failure.details.source).toBe("string"); + // Only a rejected credential is worth naming; an outage is not. + expect(failure.details.reason).toBeUndefined(); + } + }); + + it("names a rejected credential instead of calling it an outage", async () => { + // A 401 is what an ACCOUNT SWITCH looks like: the console identity is an + // OAuth browser session, not an sk-... Go key, and /usage refuses it. Folded + // into the generic status message it read as "unavailable" with nothing to + // act on, so it now carries its own reason all the way to the panel. + stubFetch(() => new Response('{"type":"error"}', { status: 401 })); + const failure = await failureOf(serviceWithKey("sk-live-key").read()); + expect(failure.message).toBe( + "OpenCode Go rejected the API key for this account" + ); + expect(failure.details.reason).toBe("auth"); + // Retrying a rejected credential cannot help, exactly as before. + expect(failure.details.retryable).toBe(false); + expect(failure.details.retainPrevious).toBe(false); + }); + + it("falls back to Zen overflow when Go usage rejects a 401 on a Zen provider", async () => { + // A Zen route (opencode) has no Go quota window: when an OAuth session or non-Go key + // answers 401 on the Go endpoint, it must fall back to the session-spend Zen overflow + // rather than displaying a spurious "Go key rejected" alert on a Zen card. + stubFetch(() => new Response('{"type":"error"}', { status: 401 })); + const service = serviceWithKey("oc_sk_zen-key"); + const reading = await service.read({ provider: "opencode" }); + expect(reading.zenOverflow).toBe(true); + expect(reading.quotaUnavailable).toBe(true); + expect(reading.monthly.percent).toBe(0); + }); + + it("falls back to Zen overflow when Go usage rejects a 401 on the Zen Google route", async () => { + // `opencode-google` is Zen's Google route, not a Go one: the same 401 that + // falls back on plain `opencode` must fall back here, rather than reaching + // the card as a rejected Go key. + stubFetch(() => new Response('{"type":"error"}', { status: 401 })); + const reading = await serviceWithKey("oc_sk_zen-key").read({ + provider: "opencode-google", + }); + expect(reading.zenOverflow).toBe(true); + expect(reading.quotaUnavailable).toBe(true); + }); + + it("rejects an oversized body before parsing it", async () => { + // The cap exists because the body is read into memory first; rejecting + // after `JSON.parse` would already have paid for the megabyte. + stubFetch(() => new Response("x".repeat(1024 * 1024 + 1))); + const failure = await failureOf(serviceWithKey("sk-live-key").read()); + expect(failure.message).toBe( + `Response from ${GO_USAGE_URL} exceeds 1048576 byte limit` + ); + expect(failure.details.retryable).toBe(true); + expect(failure.details.retainPrevious).toBe(false); + }); + + it("surfaces a transport failure against the URL it could not read", async () => { + // The meter shows the diagnostic verbatim, so the endpoint and the socket + // error both have to survive into the message. + vi.stubGlobal("fetch", (): Promise => + Promise.reject(new Error("ECONNRESET")) + ); + const failure = await failureOf(serviceWithKey("sk-live-key").read()); + expect(failure.message).toBe(`Could not read ${GO_USAGE_URL}: ECONNRESET`); + expect(failure.details.retryable).toBe(true); + expect(failure.details.retainPrevious).toBe(true); + }); + + it("reports malformed JSON as retryable", async () => { + // A truncated body from a proxy is worth retrying; the previous reading + // stays on screen meanwhile. + stubFetch(() => new Response("502 Bad Gateway")); + const failure = await failureOf(serviceWithKey("sk-live-key").read()); + expect(failure.message).toBe("Invalid JSON in OpenCode Go usage response"); + expect(failure.details.retryable).toBe(true); + expect(failure.details.retainPrevious).toBe(false); + }); + + it("reports a valid body of the wrong shape as a structure error", async () => { + // The vendor changing the payload shape is the failure a hand-rolled stub + // cannot catch, so the parse error must stay a real, retryable reading + // rather than a crash — and its cause must survive in-process. + stubFetch(() => + Response.json({ + usage: { + rolling: { + percent: 1, + resetsAt: "2026-10-01T00:00:00.000Z", + status: "ok", + }, + weekly: { + percent: 2, + resetsAt: "2026-10-02T00:00:00.000Z", + status: "ok", + }, + }, + }) + ); + const failure = await failureOf(serviceWithKey("sk-live-key").read()); + expect(failure.message).toBe( + "Invalid OpenCode Go usage response structure" + ); + expect(failure.details.retryable).toBe(true); + expect(failure.cause).toBeInstanceOf(TypeError); + expect(failure.details.source).toBeDefined(); + }); + + it("reads a healthy payload without a wrapper into the same reading", async () => { + // The gateway has shipped both shapes; both must produce one reading, so a + // wrapper-less body is not an outage. + stubFetch(() => + Response.json({ + monthly: { + percent: 3, + resetsAt: "2026-10-09T00:00:00.000Z", + status: "ok", + }, + rolling: { + percent: 4, + resetsAt: "2026-10-01T00:00:00.000Z", + status: "ok", + }, + weekly: { + percent: 5, + resetsAt: "2026-10-05T00:00:00.000Z", + status: "ok", + }, + }) + ); + const usage = await serviceWithKey("sk-live-key").read(); + expect(usage.monthly.percent).toBe(3); + expect(usage.rolling.percent).toBe(4); + expect(usage.weekly.percent).toBe(5); + expect(usage.monthly.status).toBe("ok"); + }); +}); diff --git a/test/usage-service-readings.test.ts b/test/usage-service-readings.test.ts new file mode 100644 index 00000000..96bf5140 --- /dev/null +++ b/test/usage-service-readings.test.ts @@ -0,0 +1,254 @@ +/** + * `GoUsageService` — the reading it hands back. + * + * @module test/usage-service-readings.test + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { freeZenModel } from "./support/catalog-fixture.ts"; +import { + clearCapturedApiKeys, + clearSessionUsageStore, + findModelSpecOn, + GO_MODEL_LIMITS, + recordTurnUsage, +} from "./support/src.ts"; +import { + failureOf, + okBody, + serviceWithKey, + serviceWithoutKey, + stubFetch, +} from "./support/usage-fixture.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENCODE_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + clearCapturedApiKeys(); + clearSessionUsageStore(); +}); + +describe("GoUsageService readings", () => { + it("answers the zeroed Zen-overflow reading when only Zen is configured", async () => { + // Go has no quota to report without a Go credential, so every window sits + // at zero and the meter hands over to the Zen balance. The account id is + // fresh per read here: there is no Go credential to key an identity on. + process.env.OPENCODE_API_KEY = "oc_sk_zen-key"; + const service = serviceWithoutKey(); + + const first = await service.read(); + const second = await service.read(); + expect(first.zenOverflow).toBe(true); + expect(first.rolling.percent).toBe(0); + expect(first.weekly.percent).toBe(0); + expect(first.monthly.percent).toBe(0); + expect(first.monthly.status).toBe("ok"); + // One reset instant for all three windows: a zeroed reading still has to + // say when it resets, and the meter renders one countdown. + expect(first.weekly.resetsAt).toBe(first.monthly.resetsAt); + expect(first.source).toBeDefined(); + expect(second.source).not.toBe(first.source); + }); + + it("treats an explicit Zen route as overflow even with no Zen credit", async () => { + // The client naming the Zen route is itself the signal that Go has nothing + // to say, so the meter gets the zeroed reading instead of an error. + const usage = await serviceWithoutKey().read({ provider: "opencode" }); + expect(usage.zenOverflow).toBe(true); + expect(usage.rolling.percent).toBe(0); + expect(usage.weekly.percent).toBe(0); + }); + + it("refuses to invent a reading when neither plane is configured", async () => { + // An unconfigured account is the one case with no reading to show, so the + // client is told so outright rather than handed a zeroed meter. + const service = serviceWithKey(""); + const failure = await failureOf(service.read()); + expect(failure.message).toBe("OpenCode Go API key is not configured"); + expect(failure.details).toEqual({ + configured: false, + retainPrevious: false, + retryable: false, + }); + }); + + it("attaches the spend of the conversation that asked for it", async () => { + // Spend is per conversation: two open sessions must not read the same total. + recordTurnUsage( + "session-a", + { inputTokens: 1_000_000, totalTokens: 1_000_000 }, + { input: 1, output: 1 }, + "model-a" + ); + recordTurnUsage( + "session-b", + { inputTokens: 2_000_000, totalTokens: 2_000_000 }, + { input: 1, output: 1 }, + "model-b" + ); + stubFetch(() => new Response(okBody())); + + const a = await serviceWithKey("sk-live-key").read({ + sessionId: "session-a", + }); + const b = await serviceWithKey("sk-live-key").read({ + sessionId: "session-b", + }); + expect(a.session?.totalTokens).toBe(1_000_000); + expect(a.session?.activeModel).toBe("model-a"); + expect(b.session?.totalTokens).toBe(2_000_000); + expect(b.session?.activeModel).toBe("model-b"); + }); + + it("falls back to the most recent conversation when the query names none", async () => { + // A caller that knows no session still gets spend rather than an empty + // figure — the Host can only offer the latest, so that is what it is. + recordTurnUsage( + "session-a", + { inputTokens: 10, totalTokens: 10 }, + { input: 1, output: 1 }, + "model-a" + ); + recordTurnUsage( + "session-b", + { inputTokens: 20, totalTokens: 20 }, + { input: 1, output: 1 }, + "model-b" + ); + stubFetch(() => new Response(okBody())); + + const usage = await serviceWithKey("sk-live-key").read(); + expect(usage.session?.activeModel).toBe("model-b"); + expect(usage.session?.totalTokens).toBe(20); + }); + + it("omits session spend entirely when nothing has been recorded", async () => { + // A meter that rendered "$0.00" against an empty store would be reporting a + // lie; the key is simply absent. + stubFetch(() => new Response(okBody())); + const usage = await serviceWithKey("sk-live-key").read({ + sessionId: "none", + }); + expect(Object.hasOwn(usage, "session")).toBe(false); + expect(usage.monthly.percent).toBe(100); + }); + + it("names the PICKED model even when the catalog has no price for it", async () => { + // The bug this pins: the panel named the PREVIOUS model while the picker + // showed the new one, because the re-description was gated on a catalog hit. + // A missing price is its own answer; it is never a reason to display the + // wrong model. `space-bunny-x` stands in for that case — a picked id the catalog + // has no row for. (models.dev briefly ADDED `space-bunny`, proving the + // premise is time-sensitive: assert against an id that cannot appear.) + recordTurnUsage( + "s-miss", + { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + { input: 0.15, output: 0.5 }, + "mimo-v2.6-flash-free", + true + ); + stubFetch(() => new Response(okBody())); + const usage = await serviceWithKey("sk-live-key").read({ + model: "space-bunny-x", + provider: "opencode-go", + sessionId: "s-miss", + }); + expect(usage.session?.activeModel).toBe("space-bunny-x"); + // No name and no price, but NOT the old model and NOT a leftover `freeModel` + // from the free model the session actually last ran. + expect(usage.session?.activeModelName).toBeUndefined(); + expect(usage.session?.freeModel).toBeUndefined(); + // The spend total is history and must survive the swap. + expect(usage.session?.turns).toBe(1); + }); + + it("fills in the catalog's display name for the model a turn recorded", async () => { + // A turn records the id — that is what the gateway speaks — so without this + // the meter's row showed the id where a user expects the catalog name for + // it. The model and its name are read off the Zen shim (the plane a reading + // with no route names), never pinned, so a refresh cannot retire them. + const model = freeZenModel(); + const name = findModelSpecOn("zen", model)?.name; + expect(name, `${model} has no catalog name`).toBeTruthy(); + recordTurnUsage( + "s-name", + { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + { input: 0, output: 0 }, + model, + true + ); + stubFetch(() => new Response(okBody())); + const usage = await serviceWithKey("sk-live-key").read({ + sessionId: "s-name", + }); + expect(usage.session?.activeModel).toBe(model); + expect(usage.session?.activeModelName).toBe(name); + expect(usage.session?.freeModel).toBe(true); + }); + + it("leaves the name absent for a model the catalog does not carry", async () => { + recordTurnUsage( + "s-unknown", + { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, + { input: 1, output: 2 }, + "some-model-nobody-lists", + false + ); + stubFetch(() => new Response(okBody())); + const usage = await serviceWithKey("sk-live-key").read({ + sessionId: "s-unknown", + }); + expect(usage.session?.activeModelName).toBeUndefined(); + }); + + it("attaches the selected model's monthly allowance, both tiers, and only for a listed model", async () => { + // The one thing the live payload cannot supply: what the percentages are a + // percentage OF. Resolved on the Host from the generated table, and carrying + // both tiers because the plan is not discoverable — guessing one would + // misstate the money by 2-3x. + stubFetch(() => new Response(okBody())); + const service = serviceWithKey("sk-live-key"); + + // The row is read off the generated table, never pinned, so a refresh of + // the vendor's page cannot fail this case; only the attachment is asserted. + const priced = Object.entries(GO_MODEL_LIMITS).find(([, row]) => + Number.isFinite(row.go) + ); + if (priced === undefined) { + throw new Error("the limits table carries no priced row"); + } + const [id, row] = priced; + const listed = await service.read({ model: id }); + expect(listed.allowance).toEqual({ + go: row.go, + goPlus: row.goPlus, + model: id, + }); + + // A model the plan does not publish an allowance for gets nothing at all, + // rather than a zero that would read as "you get nothing". + const unlistedId = "unlisted-model-x"; + expect(GO_MODEL_LIMITS[unlistedId]).toBeUndefined(); + const unlisted = await service.read({ model: unlistedId }); + expect(Object.hasOwn(unlisted, "allowance")).toBe(false); + + // No selection, no figure to attribute it to. + const unselected = await service.read(); + expect(Object.hasOwn(unselected, "allowance")).toBe(false); + }); + + it("marks a Go reading overflow-eligible exactly when Zen credit exists", async () => { + // `zenOverflow` on a real reading is what tells the client the Go meters + // may be backed by pay-as-you-go credit, so it must track Zen state and not + // be hard-wired. + stubFetch(() => new Response(okBody())); + const service = serviceWithKey("sk-live-key"); + const withoutZen = await service.read(); + expect(withoutZen.zenOverflow).toBe(false); + + process.env.OPENCODE_API_KEY = "oc_sk_zen-key"; + const withZen = await service.read(); + expect(withZen.zenOverflow).toBe(true); + }); +}); diff --git a/test/usage-service.test.ts b/test/usage-service.test.ts index 8ef33fc6..fa0925d9 100644 --- a/test/usage-service.test.ts +++ b/test/usage-service.test.ts @@ -10,9 +10,15 @@ * credential are injected, and the two pieces of ambient state a read consults * (captured keys, session spend) are cleared after every case. * + * This file holds the service's construction, the endpoint it builds and the + * identity it sends; its siblings hold credential failures + * (`usage-service-credentials`), readings (`usage-service-readings`) and + * gateway failures (`usage-service-failures`). Shared fixtures are + * `support/usage-fixture.ts`. + * * @module test/usage-service.test */ - +import { Context } from "@deepseek-ai/cordis"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -20,15 +26,17 @@ import { OPENCODE_PATCH_USER_AGENT, clearCapturedApiKeys, clearSessionUsageStore, - recordCapturedApiKey, - recordTurnUsage, } from "./support/src.ts"; import { - createMockContext, - headerOf, - isRecord, - type Capture, -} from "./test-helpers.ts"; + contextWithEntries, + GO_ENDPOINT, + GO_USAGE_URL, + okBody, + providerEntry, + serviceWithKey, + stubFetch, +} from "./support/usage-fixture.ts"; +import { headerOf } from "./test-helpers.ts"; afterEach(() => { vi.unstubAllGlobals(); @@ -38,130 +46,6 @@ afterEach(() => { clearSessionUsageStore(); }); -/** The Go quota endpoint the composition uses unless something overrides it. */ -const GO_ENDPOINT = "https://opencode.ai/zen/go/v1"; -/** The URL a read of {@link GO_ENDPOINT} must actually call. */ -const GO_USAGE_URL = `${GO_ENDPOINT}/usage`; - -/** The wrapped `/usage` payload the gateway answers a healthy read with. */ -const okBody = (): string => - JSON.stringify({ - usage: { - monthly: { - percent: 100, - resetsAt: "2026-10-09T13:53:58.000Z", - status: "rate-limited", - }, - rolling: { - percent: 15, - resetsAt: "2026-10-01T16:55:56.004Z", - status: "ok", - }, - weekly: { - percent: 42, - resetsAt: "2026-10-05T00:00:00.000Z", - status: "ok", - }, - }, - }); - -const urlOf = (input: RequestInfo | URL): string => { - if (typeof input === "string") { - return input; - } - if (input instanceof URL) { - return input.toString(); - } - return input.url; -}; - -/** - * Point `globalThis.fetch` at a response factory and capture the request. - * - * A factory rather than one `Response`: a body can only be read once, and the - * identity cases read the same service twice. - */ -const stubFetch = (respond: () => Response): Capture => { - const capture: Capture = { init: undefined, url: "" }; - vi.stubGlobal( - "fetch", - (input: RequestInfo | URL, init?: RequestInit): Promise => { - capture.url = urlOf(input); - capture.init = init; - return Promise.resolve(respond()); - } - ); - return capture; -}; - -/** The fields of one quota read failure the meter branches on. */ -interface UsageFailure { - cause: unknown; - code: unknown; - details: Record; - message: unknown; -} - -/** - * The typed failure one read raised, proven by shape rather than cast. - * - * Everything the meter branches on lives in `code`/`details`, so asserting the - * whole record also asserts what is *absent* — a non-configuration failure must - * not carry `configured: false`, or the client hides a retryable meter. - */ -const failureOf = async (read: Promise): Promise => { - const caught: unknown = await read.catch((error: unknown) => error); - if (!isRecord(caught) || !isRecord(caught.details)) { - throw new Error( - `expected a RemoteError carrying details, got ${String(caught)}` - ); - } - return { - cause: caught.cause, - code: caught.code, - details: caught.details, - message: caught.message, - }; -}; - -/** No-op disposer: the test process never unwinds a cordis tree. */ -const disposeNothing = (): void => { - // nothing to release outside a real cordis tree -}; - -/** One `llm-pi-ai` provider-registry entry, shaped the way discovery reads it. */ -const providerEntry = (row: Record): unknown => ({ - options: { config: { providers: { "opencode-go": row } } }, -}); - -/** - * A context satisfying both the cordis `Service` base and `discoverGoConfig`. - * - * `createMockContext` covers the first half only; the loader entries are what - * make the discovery branches reachable. - */ -const contextWithEntries = (entries: readonly unknown[]): unknown => ({ - loader: { entries: () => entries }, - reflect: { provide: () => disposeNothing }, -}); - -/** A service pinned to one endpoint and one credential. */ -const serviceWithKey = ( - key: string, - base: string = GO_ENDPOINT -): GoUsageService => - new GoUsageService(createMockContext(), { - baseURL: () => base, - resolveApiKey: () => Promise.resolve(key), - }); - -/** - * A service with no credential at all, resolved the way a cold profile does it: - * no `resolveApiKey` escape hatch, nothing captured, nothing in the environment. - */ -const serviceWithoutKey = (base: string = GO_ENDPOINT): GoUsageService => - new GoUsageService(createMockContext(), { baseURL: () => base }); - describe("GoUsageService construction", () => { it("refuses a context that is not an object before the Service base sees it", () => { // The service base registers itself on the context; a null/string/array @@ -173,9 +57,7 @@ describe("GoUsageService construction", () => { } expect(() => new GoUsageService(null)).toThrow(TypeError); // A real object-shaped context is accepted. - expect(new GoUsageService(createMockContext())).toBeInstanceOf( - GoUsageService - ); + expect(new GoUsageService(new Context())).toBeInstanceOf(GoUsageService); }); }); @@ -288,7 +170,7 @@ describe("GoUsageService identity", () => { stubFetch(() => new Response(okBody())); let base = "https://a.test/v1"; - const service = new GoUsageService(createMockContext(), { + const service = new GoUsageService(new Context(), { baseURL: () => base, resolveApiKey: () => Promise.resolve("sk-live-key"), }); @@ -318,7 +200,7 @@ describe("GoUsageService identity", () => { // in place: the next successful poll would then look like the same account. stubFetch(() => new Response(okBody())); let failing = false; - const service = new GoUsageService(createMockContext(), { + const service = new GoUsageService(new Context(), { baseURL: () => GO_ENDPOINT, resolveApiKey: (): Promise => failing @@ -336,580 +218,3 @@ describe("GoUsageService identity", () => { expect(recovered.source).not.toBe(first.source); }); }); - -describe("GoUsageService credential failures", () => { - it("reports an unresolvable credential as a configuration state", async () => { - // `configured: false` is the whole point: the client renders nothing at all - // instead of an unavailable meter the user cannot act on. - const missing = Object.assign(new Error("no Go key"), { - code: "MISSING_CREDENTIAL", - }); - const service = new GoUsageService(createMockContext(), { - baseURL: () => GO_ENDPOINT, - resolveApiKey: (): Promise => Promise.reject(missing), - }); - - const failure = await failureOf(service.read()); - expect(failure.code).toBe("opencode-go/usage-unavailable"); - expect(failure.message).toBe("OpenCode Go API key is not configured"); - expect(failure.details).toEqual({ - configured: false, - retainPrevious: false, - retryable: false, - }); - }); - - it("keeps any other resolution failure visible and retryable", async () => { - // A credentials service that throws is a fault, not a configuration: the - // previous reading stays on screen and the poll retries. `configured` must - // be absent, or the client would hide a meter that is merely broken. - for (const cause of [ - new Error("credentials service unreachable"), - Object.assign(new Error("vault sealed"), { code: "ENOLOCK" }), - ]) { - const service = new GoUsageService(createMockContext(), { - baseURL: () => GO_ENDPOINT, - resolveApiKey: (): Promise => Promise.reject(cause), - }); - const failure = await failureOf(service.read()); - expect(failure.message).toBe("Could not resolve OpenCode Go API key"); - expect(failure.details).toEqual({ - retainPrevious: false, - retryable: true, - }); - } - }); - - it("hands the configured keySource policy to credential resolution", async () => { - // The policy is the user's choice between a rotated live key and a pinned - // one; if the service ignored it, `request` setups would silently keep - // polling with a stale credential. - process.env.OPENCODE_GO_API_KEY = "sk-env-key"; - recordCapturedApiKey( - "sk-captured-key", - "opencode-go", - "https://opencode.ai/zen/go/v1/chat/completions" - ); - const context = contextWithEntries([]); - - const pinned = stubFetch(() => new Response(okBody())); - await new GoUsageService(context, { keySource: "configured" }).read(); - expect(headerOf(pinned.init, "authorization")).toBe("Bearer sk-env-key"); - - const rotated = stubFetch(() => new Response(okBody())); - await new GoUsageService(context, { keySource: "request" }).read(); - expect(headerOf(rotated.init, "authorization")).toBe( - "Bearer sk-captured-key" - ); - }); -}); - -describe("GoUsageService readings", () => { - it("answers the zeroed Zen-overflow reading when only Zen is configured", async () => { - // Go has no quota to report without a Go credential, so every window sits - // at zero and the meter hands over to the Zen balance. The account id is - // fresh per read here: there is no Go credential to key an identity on. - process.env.OPENCODE_API_KEY = "oc_sk_zen-key"; - const service = serviceWithoutKey(); - - const first = await service.read(); - const second = await service.read(); - expect(first.zenOverflow).toBe(true); - expect(first.rolling.percent).toBe(0); - expect(first.weekly.percent).toBe(0); - expect(first.monthly.percent).toBe(0); - expect(first.monthly.status).toBe("ok"); - // One reset instant for all three windows: a zeroed reading still has to - // say when it resets, and the meter renders one countdown. - expect(first.weekly.resetsAt).toBe(first.monthly.resetsAt); - expect(first.source).toBeDefined(); - expect(second.source).not.toBe(first.source); - }); - - it("treats an explicit Zen route as overflow even with no Zen credit", async () => { - // The client naming the Zen route is itself the signal that Go has nothing - // to say, so the meter gets the zeroed reading instead of an error. - const usage = await serviceWithoutKey().read({ provider: "opencode" }); - expect(usage.zenOverflow).toBe(true); - expect(usage.rolling.percent).toBe(0); - expect(usage.weekly.percent).toBe(0); - }); - - it("refuses to invent a reading when neither plane is configured", async () => { - // An unconfigured account is the one case with no reading to show, so the - // client is told so outright rather than handed a zeroed meter. - const service = serviceWithKey(""); - const failure = await failureOf(service.read()); - expect(failure.message).toBe("OpenCode Go API key is not configured"); - expect(failure.details).toEqual({ - configured: false, - retainPrevious: false, - retryable: false, - }); - }); - - it("attaches the spend of the conversation that asked for it", async () => { - // Spend is per conversation: two open sessions must not read the same total. - recordTurnUsage( - "session-a", - { inputTokens: 1_000_000, totalTokens: 1_000_000 }, - { input: 1, output: 1 }, - "model-a" - ); - recordTurnUsage( - "session-b", - { inputTokens: 2_000_000, totalTokens: 2_000_000 }, - { input: 1, output: 1 }, - "model-b" - ); - stubFetch(() => new Response(okBody())); - - const a = await serviceWithKey("sk-live-key").read({ - sessionId: "session-a", - }); - const b = await serviceWithKey("sk-live-key").read({ - sessionId: "session-b", - }); - expect(a.session?.totalTokens).toBe(1_000_000); - expect(a.session?.activeModel).toBe("model-a"); - expect(b.session?.totalTokens).toBe(2_000_000); - expect(b.session?.activeModel).toBe("model-b"); - }); - - it("falls back to the most recent conversation when the query names none", async () => { - // A caller that knows no session still gets spend rather than an empty - // figure — the Host can only offer the latest, so that is what it is. - recordTurnUsage( - "session-a", - { inputTokens: 10, totalTokens: 10 }, - { input: 1, output: 1 }, - "model-a" - ); - recordTurnUsage( - "session-b", - { inputTokens: 20, totalTokens: 20 }, - { input: 1, output: 1 }, - "model-b" - ); - stubFetch(() => new Response(okBody())); - - const usage = await serviceWithKey("sk-live-key").read(); - expect(usage.session?.activeModel).toBe("model-b"); - expect(usage.session?.totalTokens).toBe(20); - }); - - it("omits session spend entirely when nothing has been recorded", async () => { - // A meter that rendered "$0.00" against an empty store would be reporting a - // lie; the key is simply absent. - stubFetch(() => new Response(okBody())); - const usage = await serviceWithKey("sk-live-key").read({ - sessionId: "none", - }); - expect(Object.hasOwn(usage, "session")).toBe(false); - expect(usage.monthly.percent).toBe(100); - }); - - it("names the PICKED model even when the catalog has no price for it", async () => { - // The bug this pins: the panel named the PREVIOUS model while the picker - // showed the new one, because the re-description was gated on a catalog hit. - // A missing price is its own answer; it is never a reason to display the - // wrong model. `space-bunny-x` stands in for that case — a picked id the catalog - // has no row for. (models.dev briefly ADDED `space-bunny`, proving the - // premise is time-sensitive: assert against an id that cannot appear.) - recordTurnUsage( - "s-miss", - { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, - { input: 0.15, output: 0.5 }, - "mimo-v2.6-flash-free", - true - ); - stubFetch(() => new Response(okBody())); - const usage = await serviceWithKey("sk-live-key").read({ - model: "space-bunny-x", - provider: "opencode-go", - sessionId: "s-miss", - }); - expect(usage.session?.activeModel).toBe("space-bunny-x"); - // No name and no price, but NOT the old model and NOT a leftover `freeModel` - // from the free model the session actually last ran. - expect(usage.session?.activeModelName).toBeUndefined(); - expect(usage.session?.freeModel).toBeUndefined(); - // The spend total is history and must survive the swap. - expect(usage.session?.turns).toBe(1); - }); - - it("fills in the catalog's display name for the model a turn recorded", async () => { - // A turn records the id — that is what the gateway speaks — so without this - // the meter's row showed `beam-free` where a user - // expects the catalog name for it. - recordTurnUsage( - "s-name", - { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, - { input: 0, output: 0 }, - "beam-free", - true - ); - stubFetch(() => new Response(okBody())); - const usage = await serviceWithKey("sk-live-key").read({ - sessionId: "s-name", - }); - expect(usage.session?.activeModel).toBe("beam-free"); - expect(usage.session?.activeModelName).toBe("Beam Free"); - expect(usage.session?.freeModel).toBe(true); - }); - - it("leaves the name absent for a model the catalog does not carry", async () => { - recordTurnUsage( - "s-unknown", - { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, - { input: 1, output: 2 }, - "some-model-nobody-lists", - false - ); - stubFetch(() => new Response(okBody())); - const usage = await serviceWithKey("sk-live-key").read({ - sessionId: "s-unknown", - }); - expect(usage.session?.activeModelName).toBeUndefined(); - }); - - it("attaches the selected model's monthly allowance, both tiers, and only for a listed model", async () => { - // The one thing the live payload cannot supply: what the percentages are a - // percentage OF. Resolved on the Host from the generated table, and carrying - // both tiers because the plan is not discoverable — guessing one would - // misstate the money by 2-3x. - stubFetch(() => new Response(okBody())); - const service = serviceWithKey("sk-live-key"); - - const listed = await service.read({ model: "mimo-v2.6-flash" }); - expect(listed.allowance).toEqual({ - go: 60, - goPlus: 120, - model: "mimo-v2.6-flash", - }); - - // A model the plan does not publish an allowance for gets nothing at all, - // rather than a zero that would read as "you get nothing". - const unlisted = await service.read({ model: "longcat-2.5-preview-free" }); - expect(Object.hasOwn(unlisted, "allowance")).toBe(false); - - // No selection, no figure to attribute it to. - const unselected = await service.read(); - expect(Object.hasOwn(unselected, "allowance")).toBe(false); - }); - - it("marks a Go reading overflow-eligible exactly when Zen credit exists", async () => { - // `zenOverflow` on a real reading is what tells the client the Go meters - // may be backed by pay-as-you-go credit, so it must track Zen state and not - // be hard-wired. - stubFetch(() => new Response(okBody())); - const service = serviceWithKey("sk-live-key"); - const withoutZen = await service.read(); - expect(withoutZen.zenOverflow).toBe(false); - - process.env.OPENCODE_API_KEY = "oc_sk_zen-key"; - const withZen = await service.read(); - expect(withZen.zenOverflow).toBe(true); - }); -}); - -describe("GoUsageService gateway failures", () => { - it("turns a 403 EntitlementError into the Zen-overflow reading", async () => { - // The account overflowed onto Zen: Go refuses with `EntitlementError`, and - // with Zen configured the right answer is the zeroed reading rather than an - // outage. The account id carries over from the last healthy poll because it - // is still the same account. - process.env.OPENCODE_API_KEY = "oc_sk_zen-key"; - let calls = 0; - stubFetch(() => { - calls += 1; - return calls === 1 - ? new Response(okBody()) - : new Response( - '{"error":{"code":"EntitlementError","message":"no Go subscription"}}', - { status: 403 } - ); - }); - const service = serviceWithKey("sk-live-key"); - - const before = await service.read(); - const after = await service.read(); - expect(after.zenOverflow).toBe(true); - expect(after.source).toBe(before.source); - - // The zeros are a TYPE FLOOR — they exist only to satisfy the shape, and - // the flag is what stops them being drawn. Verified against the live API: - // the same key returns real figures moments after an EntitlementError, so - // asserting `percent === 0` here would pin a fabricated number as if it - // were a reading. - expect(after.quotaUnavailable).toBe(true); - expect(after.rolling.percent).toBe(0); - }); - - it("does NOT mark a healthy reading as quota-unavailable", async () => { - // Overflow being CONFIGURED is the normal case — a Zen key exists and Go - // still reports real windows. Only the unreadable reading carries the flag, - // or every Go user would lose their quota rows. - stubFetch(() => new Response(okBody())); - process.env.OPENCODE_API_KEY = "oc_sk_zen-key"; - const usage = await serviceWithKey("sk-live-key").read(); - expect(usage.quotaUnavailable).toBeUndefined(); - expect(usage.rolling.percent).toBeGreaterThan(0); - }); - - it("refuses an EntitlementError when there is no Zen credit to overflow into", async () => { - // Without Zen the account simply has no Go plan, and the meter must say so - // as an unconfigured state instead of drawing zeros forever. - stubFetch( - () => - new Response('{"error":{"code":"EntitlementError"}}', { status: 403 }) - ); - const failure = await failureOf(serviceWithKey("sk-live-key").read()); - expect(failure.message).toBe("OpenCode Go subscription required"); - expect(failure.details).toEqual({ - configured: false, - retainPrevious: false, - retryable: false, - }); - }); - - it("separates a transient HTTP status from a final one", async () => { - // 408/429/5xx are worth retrying and must keep the last reading on screen; - // 4xx means the request itself is wrong and retrying just hammers the - // gateway with the same answer. - const cases: [number, boolean][] = [ - [408, true], - [429, true], - [500, true], - [503, true], - [400, false], - [404, false], - ]; - for (const [status, transient] of cases) { - stubFetch(() => new Response("upstream said no", { status })); - const failure = await failureOf(serviceWithKey("sk-live-key").read()); - expect(failure.message).toBe( - `OpenCode Go usage unavailable (HTTP ${status})` - ); - expect(failure.details.retryable).toBe(transient); - expect(failure.details.retainPrevious).toBe(transient); - expect(typeof failure.details.source).toBe("string"); - // Only a rejected credential is worth naming; an outage is not. - expect(failure.details.reason).toBeUndefined(); - } - }); - - it("names a rejected credential instead of calling it an outage", async () => { - // A 401 is what an ACCOUNT SWITCH looks like: the console identity is an - // OAuth browser session, not an sk-... Go key, and /usage refuses it. Folded - // into the generic status message it read as "unavailable" with nothing to - // act on, so it now carries its own reason all the way to the panel. - stubFetch(() => new Response('{"type":"error"}', { status: 401 })); - const failure = await failureOf(serviceWithKey("sk-live-key").read()); - expect(failure.message).toBe( - "OpenCode Go rejected the API key for this account" - ); - expect(failure.details.reason).toBe("auth"); - // Retrying a rejected credential cannot help, exactly as before. - expect(failure.details.retryable).toBe(false); - expect(failure.details.retainPrevious).toBe(false); - }); - - it("falls back to Zen overflow when Go usage rejects a 401 on a Zen provider", async () => { - // A Zen route (opencode) has no Go quota window: when an OAuth session or non-Go key - // answers 401 on the Go endpoint, it must fall back to the session-spend Zen overflow - // rather than displaying a spurious "Go key rejected" alert on a Zen card. - stubFetch(() => new Response('{"type":"error"}', { status: 401 })); - const service = serviceWithKey("oc_sk_zen-key"); - const reading = await service.read({ provider: "opencode" }); - expect(reading.zenOverflow).toBe(true); - expect(reading.quotaUnavailable).toBe(true); - expect(reading.monthly.percent).toBe(0); - }); - - it("falls back to Zen overflow when Go usage rejects a 401 on the Zen Google route", async () => { - // `opencode-google` is Zen's Google route, not a Go one: the same 401 that - // falls back on plain `opencode` must fall back here, rather than reaching - // the card as a rejected Go key. - stubFetch(() => new Response('{"type":"error"}', { status: 401 })); - const reading = await serviceWithKey("oc_sk_zen-key").read({ - provider: "opencode-google", - }); - expect(reading.zenOverflow).toBe(true); - expect(reading.quotaUnavailable).toBe(true); - }); - - it("rejects an oversized body before parsing it", async () => { - // The cap exists because the body is read into memory first; rejecting - // after `JSON.parse` would already have paid for the megabyte. - stubFetch(() => new Response("x".repeat(1024 * 1024 + 1))); - const failure = await failureOf(serviceWithKey("sk-live-key").read()); - expect(failure.message).toBe( - `Response from ${GO_USAGE_URL} exceeds 1048576 byte limit` - ); - expect(failure.details.retryable).toBe(true); - expect(failure.details.retainPrevious).toBe(false); - }); - - it("surfaces a transport failure against the URL it could not read", async () => { - // The meter shows the diagnostic verbatim, so the endpoint and the socket - // error both have to survive into the message. - vi.stubGlobal("fetch", (): Promise => - Promise.reject(new Error("ECONNRESET")) - ); - const failure = await failureOf(serviceWithKey("sk-live-key").read()); - expect(failure.message).toBe(`Could not read ${GO_USAGE_URL}: ECONNRESET`); - expect(failure.details.retryable).toBe(true); - expect(failure.details.retainPrevious).toBe(true); - }); - - it("reports malformed JSON as retryable", async () => { - // A truncated body from a proxy is worth retrying; the previous reading - // stays on screen meanwhile. - stubFetch(() => new Response("502 Bad Gateway")); - const failure = await failureOf(serviceWithKey("sk-live-key").read()); - expect(failure.message).toBe("Invalid JSON in OpenCode Go usage response"); - expect(failure.details.retryable).toBe(true); - expect(failure.details.retainPrevious).toBe(false); - }); - - it("reports a valid body of the wrong shape as a structure error", async () => { - // The vendor changing the payload shape is the failure a hand-rolled stub - // cannot catch, so the parse error must stay a real, retryable reading - // rather than a crash — and its cause must survive in-process. - stubFetch(() => - Response.json({ - usage: { - rolling: { - percent: 1, - resetsAt: "2026-10-01T00:00:00.000Z", - status: "ok", - }, - weekly: { - percent: 2, - resetsAt: "2026-10-02T00:00:00.000Z", - status: "ok", - }, - }, - }) - ); - const failure = await failureOf(serviceWithKey("sk-live-key").read()); - expect(failure.message).toBe( - "Invalid OpenCode Go usage response structure" - ); - expect(failure.details.retryable).toBe(true); - expect(failure.cause).toBeInstanceOf(TypeError); - expect(failure.details.source).toBeDefined(); - }); - - it("reads a healthy payload without a wrapper into the same reading", async () => { - // The gateway has shipped both shapes; both must produce one reading, so a - // wrapper-less body is not an outage. - stubFetch(() => - Response.json({ - monthly: { - percent: 3, - resetsAt: "2026-10-09T00:00:00.000Z", - status: "ok", - }, - rolling: { - percent: 4, - resetsAt: "2026-10-01T00:00:00.000Z", - status: "ok", - }, - weekly: { - percent: 5, - resetsAt: "2026-10-05T00:00:00.000Z", - status: "ok", - }, - }) - ); - const usage = await serviceWithKey("sk-live-key").read(); - expect(usage.monthly.percent).toBe(3); - expect(usage.rolling.percent).toBe(4); - expect(usage.weekly.percent).toBe(5); - expect(usage.monthly.status).toBe("ok"); - }); -}); - -describe("usage service: the read that a silent meter depends on", () => { - /** - * The whole path a working composition takes: the loader's rows name the Go - * route, the route names the credential reference, the service resolves it and - * reads the endpoint. - * - * It exists because "the meter shows nothing" is not evidence about which link - * broke, and this asserts the links the SERVICE owns. A probe of the same path - * against a real-shaped row and a real key answered with real window figures, - * so a silent meter is a wiring question rather than a service one - and this - * keeps that true. - */ - const goRow = { - options: { - config: { - providers: { "opencode-go": { apiKeyEnv: "OPENCODE_GO_API_KEY" } }, - }, - name: "@deepseek-ai/dsh-llm-pi-ai", - }, - }; - - it("resolves its key and reads the endpoint", async () => { - const real = globalThis.fetch; - const asked: string[] = []; - globalThis.fetch = (async ( - input: unknown, - init?: { headers?: unknown } - ) => { - const headers = new Headers(init?.headers as HeadersInit | undefined); - asked.push(`${String(input)} ${headers.get("authorization") ?? ""}`); - return Response.json({ - usage: { - monthly: { - percent: 44, - resetsAt: "2026-11-01T00:00:00.000Z", - status: "ok", - }, - rolling: { - percent: 0, - resetsAt: "2026-10-12T07:20:40.000Z", - status: "ok", - }, - weekly: { - percent: 0, - resetsAt: "2026-10-19T00:00:00.000Z", - status: "ok", - }, - }, - }); - }) as typeof globalThis.fetch; - - try { - const ctx = createMockContext() as Record; - ctx.loader = { entries: () => [goRow] }; - ctx.get = (name: string) => - name === "credentials" - ? { - resolve: (ref: string) => - Promise.resolve( - ref === "OPENCODE_GO_API_KEY" - ? { value: "go-key" } - : undefined - ), - } - : undefined; - - const service = new GoUsageService(ctx, { keySource: "auto" }); - const usage = await service.read({ provider: "opencode-go" }); - - // The endpoint was asked with the Go key, and the figures came back. - expect(asked).toHaveLength(1); - expect(asked[0]).toContain("/zen/go/v1/usage"); - expect(asked[0]).toContain("Bearer go-key"); - expect(usage.weekly.percent).toBe(0); - expect(usage.monthly.percent).toBe(44); - expect(usage.quotaUnavailable ?? false).toBe(false); - } finally { - globalThis.fetch = real; - } - }); -}); diff --git a/test/usage.test.ts b/test/usage.test.ts index 9b00ac62..153c68f4 100644 --- a/test/usage.test.ts +++ b/test/usage.test.ts @@ -9,23 +9,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - GoUsageService, - KEY_SOURCE_POLICIES, clearCapturedApiKeys, - clearSessionUsageStore, - discoverGoConfig, extractApiKeyFromHeaders, getCapturedApiKey, isPlaceholderApiKey, parseGoUsage, parseUsageQuery, recordCapturedApiKey, - recordTurnUsage, - resolveGoApiKey, - resolveZenCreditInfo, tierForRequest, } from "./support/src.ts"; -import { createMockContext } from "./test-helpers.ts"; afterEach(() => { vi.unstubAllGlobals(); @@ -98,235 +90,6 @@ describe("OpenCode Go Usage", () => { expect(() => parseUsageQuery("nope")).toThrow(/expected an object/); }); - it("scopes session spend to the conversation that asked for it", async () => { - clearSessionUsageStore(); - // Two conversations have spend; the Host must not conflate them. - recordTurnUsage( - "session-a", - { inputTokens: 1_000_000, totalTokens: 1_000_000 }, - { input: 1, output: 1 }, - "model-a" - ); - recordTurnUsage( - "session-b", - { inputTokens: 2_000_000, totalTokens: 2_000_000 }, - { input: 1, output: 1 }, - "model-b" - ); - - const originalFetch = globalThis.fetch; - try { - // A fresh Response per call: a Response body can only be read once, and - // this test reads twice. - globalThis.fetch = vi.fn().mockImplementation(async () => - Response.json({ - usage: { - monthly: { - percent: 1, - resetsAt: new Date().toISOString(), - status: "ok", - }, - rolling: { - percent: 1, - resetsAt: new Date().toISOString(), - status: "ok", - }, - weekly: { - percent: 1, - resetsAt: new Date().toISOString(), - status: "ok", - }, - }, - }) - ); - const service = new GoUsageService(createMockContext(), { - baseURL: () => "https://opencode.ai/zen/go/v1", - resolveApiKey: () => Promise.resolve("test_key_123"), - }); - - const a = await service.read({ sessionId: "session-a" }); - expect(a.session?.activeModel).toBe("model-a"); - expect(a.session?.totalTokens).toBe(1_000_000); - - const b = await service.read({ sessionId: "session-b" }); - expect(b.session?.activeModel).toBe("model-b"); - expect(b.session?.totalTokens).toBe(2_000_000); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("GoUsageService throws RemoteError when API key is missing", async () => { - const service = new GoUsageService(createMockContext(), { - baseURL: () => "https://opencode.ai/zen/go/v1", - resolveApiKey: () => Promise.resolve(""), - }); - await expect(service.read()).rejects.toThrow( - "OpenCode Go API key is not configured" - ); - }); - - it("GoUsageService reads and parses successfully with valid mock fetch", async () => { - const originalFetch = globalThis.fetch; - try { - globalThis.fetch = vi - .fn() - .mockResolvedValue(Response.json(samplePayload)); - - const service = new GoUsageService(createMockContext(), { - baseURL: () => "https://opencode.ai/zen/go/v1", - resolveApiKey: () => Promise.resolve("test_key_123"), - }); - - const usage = await service.read(); - expect(usage.monthly.percent).toBe(100); - expect(usage.weekly.percent).toBe(42); - expect(usage.source).toBeDefined(); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("GoUsageService handles upstream 429/temporary error with RemoteError", async () => { - const originalFetch = globalThis.fetch; - try { - globalThis.fetch = vi - .fn() - .mockResolvedValue( - new Response("rate limit exceeded", { status: 429 }) - ); - - const service = new GoUsageService(createMockContext(), { - baseURL: () => "https://opencode.ai/zen/go/v1", - resolveApiKey: () => Promise.resolve("test_key_123"), - }); - - await expect(service.read()).rejects.toThrow( - "OpenCode Go usage unavailable (HTTP 429)" - ); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("discoverGoConfig discovers provider config from llm-pi-ai entries", () => { - const mockCtx = { - loader: { - entries: () => [ - { - options: { - config: { - providers: { - "opencode-go": { - apiKeyEnv: "CUSTOM_GO_KEY_ENV", - baseURL: "https://custom-gateway.com/zen/go/v1", - }, - }, - }, - id: "llm-pi-ai", - name: "@deepseek-ai/dsh-llm-pi-ai", - }, - }, - ], - }, - }; - - const discovered = discoverGoConfig(mockCtx); - expect(discovered.keyEnv).toBe("CUSTOM_GO_KEY_ENV"); - expect(discovered.baseURL).toBe("https://custom-gateway.com/zen/go/v1"); - }); - - it("discoverGoConfig discovers literal apiKey from standalone opencode-go entry", () => { - const mockCtx = { - loader: { - entries: () => [ - { - options: { - config: { - apiKey: "sk-literal-test-key", - }, - id: "opencode-go", - name: "dsh-opencode-go", - }, - }, - ], - }, - }; - - const discovered = discoverGoConfig(mockCtx); - expect(discovered.literalKey).toBe("sk-literal-test-key"); - }); - - it("discoverGoConfig discovers literal apiKey from headers.authorization in provider entry", () => { - const mockCtx = { - loader: { - entries: () => [ - { - options: { - config: { - providers: { - "opencode-go": { - headers: { - authorization: "Bearer sk-header-discovered-key", - }, - }, - }, - }, - id: "llm-pi-ai", - }, - }, - ], - }, - }; - - const discovered = discoverGoConfig(mockCtx); - expect(discovered.literalKey).toBe("sk-header-discovered-key"); - }); - - it("discoverGoConfig discovers literal apiKey from options.headers and options.apiKey", () => { - const mockCtxHeaders = { - loader: { - entries: () => [ - { - options: { - config: { - options: { - headers: { - "x-api-key": "sk-options-headers-key", - }, - }, - }, - id: "opencode-go", - }, - }, - ], - }, - }; - expect(discoverGoConfig(mockCtxHeaders).literalKey).toBe( - "sk-options-headers-key" - ); - - const mockCtxOptionKey = { - loader: { - entries: () => [ - { - options: { - config: { - options: { - apiKey: "sk-options-direct-key", - }, - }, - id: "opencode-go", - }, - }, - ], - }, - }; - expect(discoverGoConfig(mockCtxOptionKey).literalKey).toBe( - "sk-options-direct-key" - ); - }); - it("extractApiKeyFromHeaders extracts keys from Headers and plain objects", () => { const h1 = new Headers(); h1.set("authorization", "Bearer sk-test-bearer-123"); @@ -348,44 +111,6 @@ describe("OpenCode Go Usage", () => { expect(extractApiKeyFromHeaders({})).toBeUndefined(); }); - it("resolveGoApiKey resolves key from captured request header when env is unset", async () => { - delete process.env.OPENCODE_GO_API_KEY; - delete process.env.OPENCODE_API_KEY; - const mockCtx = { loader: { entries: () => [] } }; - - // Initially unset - expect(await resolveGoApiKey(mockCtx)).toBeUndefined(); - - // Now record a key that was captured from a live request header - recordCapturedApiKey( - "sk-live-captured-key-888", - "opencode-go", - "https://opencode.ai/zen/go/v1/chat/completions" - ); - - expect(getCapturedApiKey("opencode-go", "go")).toBe( - "sk-live-captured-key-888" - ); - expect(await resolveGoApiKey(mockCtx)).toBe("sk-live-captured-key-888"); - }); - - it("resolveZenCreditInfo detects captured Zen key when env is unset", async () => { - delete process.env.OPENCODE_API_KEY; - const mockCtx = { loader: { entries: () => [] } }; - - const before = await resolveZenCreditInfo(mockCtx); - expect(before.isConfigured).toBe(false); - - recordCapturedApiKey( - "oc_sk_live_captured_zen_key", - "opencode", - "https://opencode.ai/zen/v1/messages" - ); - - const after = await resolveZenCreditInfo(mockCtx); - expect(after.isConfigured).toBe(true); - }); - it("never records a placeholder as a captured key", () => { recordCapturedApiKey( "sk-real-key-111", @@ -557,70 +282,4 @@ describe("OpenCode Go Usage", () => { expect(tierForRequest("", "")).toBe("unknown"); }); }); - - describe("keySource policy", () => { - /** A composition that declares a literal Go key, as another row may. */ - const withLiteralKey = () => ({ - loader: { - entries: () => [ - { - options: { - config: { - providers: { "opencode-go": { apiKey: "sk-literal-key" } }, - }, - }, - }, - ], - }, - }); - const withoutDeclarations = { loader: { entries: () => [] } }; - const captureGoKey = () => - recordCapturedApiKey( - "sk-captured-key", - "opencode-go", - "https://opencode.ai/zen/go/v1/chat/completions" - ); - - it("auto keeps the declared literal ahead of a captured key", async () => { - captureGoKey(); - expect(await resolveGoApiKey(withLiteralKey(), undefined, "auto")).toBe( - "sk-literal-key" - ); - }); - - it("request promotes a captured key above the declared literal", async () => { - // The whole point of the policy: a rotated live key beats a pinned one. - captureGoKey(); - expect( - await resolveGoApiKey(withLiteralKey(), undefined, "request") - ).toBe("sk-captured-key"); - }); - - it("configured prefers the declared credential over a captured key", async () => { - process.env.OPENCODE_GO_API_KEY = "sk-env-key"; - captureGoKey(); - // auto and request both take the live key… - expect( - await resolveGoApiKey(withoutDeclarations, undefined, "auto") - ).toBe("sk-captured-key"); - expect( - await resolveGoApiKey(withoutDeclarations, undefined, "request") - ).toBe("sk-captured-key"); - // …while configured takes the declared one. - expect( - await resolveGoApiKey(withoutDeclarations, undefined, "configured") - ).toBe("sk-env-key"); - }); - - it("every policy still falls back to a captured key when nothing declares one", async () => { - // Cold start is the case capture cannot serve, and it is why credentials - // and the environment stay in every policy's order. - captureGoKey(); - for (const policy of KEY_SOURCE_POLICIES) { - expect( - await resolveGoApiKey(withoutDeclarations, undefined, policy) - ).toBe("sk-captured-key"); - } - }); - }); }); diff --git a/vite.config.ts b/vite.config.ts index b5f1c677..f55fe758 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -240,14 +240,17 @@ export default defineConfig({ // once the hidden-route modules (about 4,000 lines, the least covered // in the tree) were deleted and the plane modules that replaced them // landed at 100% where they are pure. Set just below that measurement. - statements: 97.5, + // Raised in P-T2 to just below 98.2 / 95.6 / 98.01 / 98.19, measured + // after the mock-booting files left the unit run and the floor pins + // below landed. + statements: 98, // 91.5 -> 91.3: two tests that pinned hand-rolled menu-material CSS were // replaced by two that assert the panel renders MenuSurface, which is a // smaller assertion about a bigger fix. The floor follows the measured // value rather than the other way round. 94 since P-R1 (above). - branches: 94, - functions: 97.4, - lines: 97.5, + branches: 95.4, + functions: 97.9, + lines: 98, // Floors on the load-bearing modules only, so a well-covered average // cannot hide one that stopped being exercised. Not `perFile: true` — // that would hold every file to the aggregate number, which is a @@ -307,6 +310,47 @@ export default defineConfig({ functions: 100, lines: 100, }, + // The three modules that install the plugin's effects on the host (P-T2): + // the composition, the stream hook and the fetch patch. Measured at + // 92.72 / 92.15 / 100 / 92.59, 100 / 97.72 / 100 / 100 and + // 100 / 98 / 100 / 100 once every branch a composition can reach was + // pinned (lifecycle: the rename notice on tier B, the usage service's + // base-URL closure, a composition with no `get`; stream-hook: numeric + // ids, a blank-named root, `cwd: "/"`, a non-string model, a usage + // chunk with one counter; fetch-patch: the `configured` policy, bodies + // the free-model check cannot read, a placeholder in the env, the + // store read, a blank `sessionIdEnv`, a debug entry of nulls), and set + // just below. What is left uncovered no composition reaches, and is + // named here rather than driven through a stand-in: + // - lifecycle.ts: `root.plugin()` throwing for the usage, purge and + // store services (L172-173, L258-261, L283-284), `ctx.get` throwing + // (L213), a usage binding whose namespace is not a string (L233), the + // debug hook's `debugFile ?? ""` behind its own guard (L324), an + // `llm` scope that is not a record or has no `effect` (L370-371, L375); + // - stream-hook.ts: `headerValueFor` answering `undefined` for a + // non-empty session id (L149, L154), which it never does; + // - fetch-patch.ts: the report's "present" for a request key or a + // captured key (L319, L321), since either one fills the wire and no + // report follows, and a session header that is null after the patch + // set it (L466). + "src/lifecycle.ts": { + statements: 92.5, + branches: 92, + functions: 100, + lines: 92.5, + }, + "src/stream-hook.ts": { + statements: 100, + branches: 97.5, + functions: 100, + lines: 100, + }, + "src/fetch-patch.ts": { + statements: 100, + branches: 97.5, + functions: 100, + lines: 100, + }, // Where every host library the planes run on is found. Its fallbacks // (a tree missing a package, a manifest of another shape) are driven // through stand-in installs; what is left uncovered are guards for diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index f2db2fcc..11ebe39c 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -24,8 +24,9 @@ export default defineConfig({ // These cross the public internet from CI, where 5s is far too tight. hookTimeout: 30_000, testTimeout: 30_000, - // One file at a time: the live gateway rate-limits, and the header suite - // binds a socket. + // One file at a time: the live gateway rate-limits, and the host-binary + // file boots a dsh server of its own. (The suites that bind a loopback + // socket are the mock suite's, `vitest.mock.config.ts`.) fileParallelism: false, }, }); diff --git a/vitest.mock.config.ts b/vitest.mock.config.ts index 2ebf327b..15475227 100644 --- a/vitest.mock.config.ts +++ b/vitest.mock.config.ts @@ -19,6 +19,8 @@ export default defineConfig({ test: { ...base.test, // Only the mock files; the unit `include` from the base config is replaced. + // They keep the base config's file parallelism: each socket they bind is + // an ephemeral loopback port of its own. include: ["test/mock/**/*.mock.ts"], // A Vite server boots inside each file and pi parses a full stream per // case; neither is slow, but 5s is a unit-run budget, not this one. From c0ac147fd9d38937b5f0b7662c925980a1039043 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:31:26 +0000 Subject: [PATCH 2/2] chore: refresh the generated catalog after Zen retired deepseek-v4-flash Ported from P-R2 (29b43ca, itself from d13cbe2 on #105) so this pull request's own `catalog freshness` goes green now, rather than waiting for P3 to carry it up. It no-ops once the merge from P3 brings the same change. It takes `src/catalog-data.ts` exactly as the nightly refresh produced it (#111), and the counts the docs restate follow it (Zen 81 served, no-SDK 24, "of 81"). test/catalog.test.ts keeps this branch's invariants, which hold the shim to its own rules rather than to a count, so it needed no change. (cherry picked from commit 29b43cadeba7699b421855251c267f5365dc2f5f) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- docs/protocol-routing-and-merge.md | 10 +++++----- docs/redirect-planes.md | 6 +++--- src/catalog-data.ts | 17 ----------------- 5 files changed, 12 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index b8c7737e..2204c60d 100644 --- a/README.md +++ b/README.md @@ -160,13 +160,13 @@ Zen's provider-level SDK is `@ai-sdk/openai-compatible`. models.dev names a **di | models.dev `provider.npm` | models | protocol | | :-- | --: | :-- | -| _(absent)_ | 25 | OpenAI Chat Completions | +| _(absent)_ | 24 | OpenAI Chat Completions | | `@ai-sdk/openai` | 31 | OpenAI Responses | | `@ai-sdk/anthropic` | 17 | Anthropic Messages | | `@ai-sdk/google` | 8 | Google Generative AI | | `@ai-sdk/mistral` | 1 | OpenAI Chat Completions, as pi's own data files it | -Counts are the **82** active `opencode` models in `models.opencode.ai` as of 2026-10-23 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 82 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. +Counts are the **81** active `opencode` models in `models.opencode.ai` as of 2026-10-23 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 81 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. Four properties follow: diff --git a/README.zh-CN.md b/README.zh-CN.md index 937aa59b..97cfcc23 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -158,13 +158,13 @@ Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在 | models.dev `provider.npm` | 模型数 | 协议 | | :-- | --: | :-- | -| _(缺失)_ | 25 | OpenAI Chat Completions | +| _(缺失)_ | 24 | OpenAI Chat Completions | | `@ai-sdk/openai` | 31 | OpenAI Responses | | `@ai-sdk/anthropic` | 17 | Anthropic Messages | | `@ai-sdk/google` | 8 | Google Generative AI | | `@ai-sdk/mistral` | 1 | OpenAI Chat Completions(与 pi 自己的数据一致) | -数量为 `models.opencode.ai` 中 `opencode` 的 82 个**在用**模型,统计于 2026-10-23 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 82 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 +数量为 `models.opencode.ai` 中 `opencode` 的 81 个**在用**模型,统计于 2026-10-23 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 81 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 由此有四条性质: diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index 4474c4be..ea7c1b91 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -8,11 +8,11 @@ OpenCode serves its models on **four different API shapes**, and which one a mod | API shape | Wire endpoint (Zen) | Which models | Signal in the catalog | | :-- | :-- | :-- | :-- | -| OpenAI **Chat Completions** | `POST /inference/openai/v1/chat/completions` | **the default**: 25 of 82 | the model names **no** SDK | -| OpenAI **Responses** | `POST /inference/openai/v1/responses` | 31 of 82 | `@ai-sdk/openai` | -| Anthropic **Messages** | `POST /inference/anthropic/v1/messages` | 17 of 82 | `@ai-sdk/anthropic` | -| Google **Generative AI** | `POST /inference/google/v1beta/models/:streamGenerateContent` | 8 of 82 | `@ai-sdk/google` | -| OpenAI **Chat Completions** (as pi files it) | `POST /inference/openai/v1/chat/completions` | 1 of 82 | `@ai-sdk/mistral` | +| OpenAI **Chat Completions** | `POST /inference/openai/v1/chat/completions` | **the default**: 24 of 81 | the model names **no** SDK | +| OpenAI **Responses** | `POST /inference/openai/v1/responses` | 31 of 81 | `@ai-sdk/openai` | +| Anthropic **Messages** | `POST /inference/anthropic/v1/messages` | 17 of 81 | `@ai-sdk/anthropic` | +| Google **Generative AI** | `POST /inference/google/v1beta/models/:streamGenerateContent` | 8 of 81 | `@ai-sdk/google` | +| OpenAI **Chat Completions** (as pi files it) | `POST /inference/openai/v1/chat/completions` | 1 of 81 | `@ai-sdk/mistral` | Counts are the shipped catalog. They are asserted against this table by `catalog-docs.test.ts`, because a count written into prose does not regenerate with the data it counts. Go's endpoints are the same paths under `/inference/go/…`; `endpoint: legacy` sends every protocol to `/zen/v1` (`/zen/go/v1` on Go), except Go's Google, which only the inference family serves. diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 2fefd4ab..5c1569b5 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -10,7 +10,7 @@ A **plane** is one gateway base URL with its own catalog and its own credential. | Plane | Route id | Base URL | Credential | Models | | :-- | :-- | :-- | :-- | --: | -| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | the entry's `apiKeyEnv`; without one, pi's `OPENCODE_API_KEY` | 82 served / 127 listed | +| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | the entry's `apiKeyEnv`; without one, pi's `OPENCODE_API_KEY` | 81 served / 127 listed | | Go (subscription) | `opencode-go` | the `endpoint` family's | the entry's `apiKeyEnv` (name `OPENCODE_GO_API_KEY`); without one, pi's `OPENCODE_API_KEY` | 30 served / 34 listed | **There is no longer one base URL per plane to quote.** The gateway answers on two endpoint families and the `endpoint` setting picks one: `auto`, the default, is an alias of `modern` (`AUTO_ENDPOINT_FAMILY` in `src/utils/plane-model.ts`), the per-protocol `…/inference/{openai,anthropic,google}/…` and `…/inference/go/…`; `legacy` pins the older `…/zen/v1` and `…/zen/go/v1`, except Go's Google, which has no legacy base and stays on `…/inference/go/google/v1beta`. Which families answer, keyless and with each plane's own key, is measured in CI (§13), not assumed. The dated measurement below was taken on the legacy family. @@ -25,13 +25,13 @@ A **shape** is one wire protocol, named by the SDK a model declares in the catal | SDK in the catalog | Protocol | Zen | Go | | :------------------ | :--------------------- | --: | ----: | -| _(none)_ | `openai-completions` | 25 | 19 | +| _(none)_ | `openai-completions` | 24 | 19 | | `@ai-sdk/openai` | `openai-responses` | 31 | **5** | | `@ai-sdk/anthropic` | `anthropic-messages` | 17 | **5** | | `@ai-sdk/mistral` | `openai-completions` | 1 | — | | `@ai-sdk/google` | `google-generative-ai` | 8 | **1** | -**Absence is the signal, and it is a real one.** Both planes set a provider-level `npm` of `@ai-sdk/openai-compatible` in `models.dev`, and **no model declares it explicitly**: 0 of 82 on Zen, 0 of 30 on Go. A model that speaks the completions shape simply omits `provider.npm` and inherits it. So "no SDK" does not mean "unknown"; it means _the provider default_, which is the protocol the route was itself registered with. +**Absence is the signal, and it is a real one.** Both planes set a provider-level `npm` of `@ai-sdk/openai-compatible` in `models.dev`, and **no model declares it explicitly**: 0 of 81 on Zen, 0 of 30 on Go. A model that speaks the completions shape simply omits `provider.npm` and inherits it. So "no SDK" does not mean "unknown"; it means _the provider default_, which is the protocol the route was itself registered with. That is why the servability check treats `undefined` as servable, and why `@ai-sdk/openai-compatible` is deliberately **not** in `PROTOCOL_FOR_SDK`: listing it would record the default as though it were an override. The coverage guard still covers the case where a model declares it explicitly. None do today, and if one did, the test would name it rather than drop the model. diff --git a/src/catalog-data.ts b/src/catalog-data.ts index 30904b62..a9285d67 100644 --- a/src/catalog-data.ts +++ b/src/catalog-data.ts @@ -869,23 +869,6 @@ export const OPENCODE_ZEN_CATALOG: readonly CatalogModelSpec[] = [ { type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }, ], }, - { - context_window: 1_000_000, - cost: { - cache_read: 0.028, - input: 0.14, - output: 0.28, - }, - id: "deepseek-v4-flash", - input_modalities: ["text"], - max_output_tokens: 384_000, - name: "DeepSeek V4 Flash", - reasoning: true, - reasoning_options: [ - { type: "toggle" }, - { type: "effort", values: ["low", "high", "max"] }, - ], - }, { context_window: 1_000_000, cost: {