diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d644f098..221030c6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -56,12 +56,12 @@ updates: - vitest - "@vitest/*" ignore: - # pi-ai follows the line the main dsh installs, not the registry. The bridge - # loads pi from the wrapper's copy, so our pin has to satisfy the range the + # pi-ai follows the line the main dsh installs, not the registry. The plane + # adapter loads pi from the wrapper's copy, so our pin has to satisfy the range the # wrapper declares - now `^1.1.0`, where minor releases stay inside the range # and a major does not. A second pi beside the wrapper's is the failure this # prevents: two module instances drift apart the moment either side moves, and - # `test/google-bridge.test.ts` asserts the pin satisfies the range, so a major + # `test/host-versions.test.ts` asserts the pin satisfies the range, so a major # here would land red rather than break something quietly. Minors are # welcome - they stay inside the wrapper's range - so only majors are ignored. - dependency-name: "@earendil-works/pi-ai" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10da8265..cb0a2ed5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -374,17 +374,22 @@ jobs: # package.json is not (every dependency bump touches it), so a # change to its `test:e2e:host` script alone is exercised only by # the nightly run. - # Until P-R1 moves the host seam under src/host/, the modules that - # install the host patches and mounts whose behaviour depends on the - # real loader and apply order (the hidden-route mount, the listing - # patch and adapter decoration, the Google bridge, the fetch patch, - # the plane bases and the stream hook) are in scope by name. Every - # other row producer (key-capture.ts, 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 host seam lives under 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 + # sit beside it by name: the composition (lifecycle.ts, identity.ts), + # the host-module seam (cordis-context.ts), the remaining discovery + # patch (models-discovery.ts), and the two modules that still install + # host-level patches until P6/P10a move them under src/host/: the + # global fetch wrapper (fetch-patch.ts, whose order the probe reads + # as fetchInnermost) and the llm/stream hook (stream-hook.ts). Every + # other row producer (the pure + # plane profile and model in src/utils/, key-capture.ts, + # 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. if printf '%s\n' "$changed" \ - | grep -Eq '^(src/host/|src/lifecycle\.ts$|src/identity\.ts$|src/responses-provider\.ts$|src/responses-routes\.ts$|src/models-discovery\.ts$|src/google-bridge\.ts$|src/fetch-patch\.ts$|src/pi-provider\.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$)'; then + | 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$)'; then echo "run=true" >> "$GITHUB_OUTPUT" else echo "run=false" >> "$GITHUB_OUTPUT" diff --git a/AGENTS.md b/AGENTS.md index 9d491c64..8298241c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,8 +43,9 @@ 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/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 (`todayRowsFor` picks them by the apply order the probe infers from whether the plugin's decoration wrapped pi-ai's plane adapter). Tier A has no loader, so this plugin mounted there cannot find pi-ai and mounts no hidden route and no Google bridge (whose adapter class comes from the same loader entry, `loadPiAiAdapterHost`); `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 finds pi-ai's entry by `options.name` (`piAiEntry` in `src/responses-provider.ts`) and a builtin-named row would drop the hidden-route mount from the characterization. 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; `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/keyless-matrix.ts` — the keyless 401-vs-404 probe matrix's cell table and classifier. 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/live-walk.ts` · `test/support/live-probe.ts` — the live adapter blocks' candidate walk (pure: a protocol walks its candidates until a turn reaches the wire, and one no candidate reached is named, never dropped) and the raw protocol-shaped probe that confirms a withdrawn model before a walk moves past it (its tables are the ones `protocol-routing.e2e.ts` probes with). - `test/e2e/host/` — the measurement bundle (`probe-plugin.mjs`, its manifest and patch) the `e2e-host` job links into a throwaway profile under a temporary HOME, through a relative `link:` path. `test/e2e/host-binary.e2e.ts` is its reader, gated on `DSH_HOST_E2E=1`; it installs no host itself and needs `DSH_HOST_PREFIX`, a prefix holding the installed one. - `patches/` — the harness's own pi patch, copied verbatim and applied by `patchedDependencies` in `pnpm-workspace.yaml`. Its hash is pinned by `test/host-versions.test.ts` and recorded in `pnpm-lock.yaml`, so never hand-edit it: re-copy it from the harness and re-run `pnpm install`. - `src/config.ts` · `src/config-values.ts` · `src/utils/guards.ts` — the schema and its defaults; dependency-free readers and type guards. The client bundle may import these, and it does — along with the contract and presentation modules (`usage-contract.ts`, `login-contract.ts`, `settings-copy.ts`, `settings-fields.ts`, `usage-ui.ts`, `go-limits.ts`). The rule is not a list of names: **the client may import a module that reaches nothing host-only** — no cordis context, no credential seam, no node built-in — and the bundle gate is what enforces it, by failing the build rather than by anyone remembering. An earlier version of this line named two files as the only ones allowed, which the tree had already outgrown. @@ -73,12 +74,17 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f ## What moved -| to `utils/` | from | -| :----------------- | :------------------------------------------------ | -| `request-shape.ts` | new (extracted in #74) | -| `guards.ts` | `src/` | -| `session-id.ts` | the rules inside `session.ts` | -| `google-replay.ts` | `src/` (pure translation, so the gate polices it) | +| to `utils/` | from | +| :-- | :-- | +| `request-shape.ts` | new (extracted in #74) | +| `guards.ts` | `src/` | +| `session-id.ts` | the rules inside `session.ts` | +| `protocols.ts` | `responses-routes.ts` and `pi-provider.ts`'s tables (P-R1) | +| `model-metadata.ts` | new (P-R1): the reasoning vocabularies, pi-ai's effort rules | +| `plane-model.ts` | new (P-R1): one declared model → the pi model object | +| `plane-profile.ts` | new (P-R1): one plane entry → pi-ai's profile shape | + +`google-replay.ts` lived here until P-R1, and went once a Go Gemini replay stayed on one provider label (`test/mock/plane-dispatch.mock.ts`). - `src/session.ts` · `src/turn-store.ts` — the `ses_<12hex><14base62>` derivation and the `AsyncLocalStorage` turn store. - `src/stream-hook.ts` · `src/fetch-patch.ts` · `src/tool-fallback.ts` — the `llm/stream` hook, the fetch interceptor, and the free-tier `read`/`bash` fallback. @@ -92,13 +98,12 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f - `src/go-limits.ts` — the allowance lookup, the label normalization and the money formatting. `allowanceFor` is the one lookup: `attachAllowance` in `usage.ts` calls it rather than inlining a second copy, which it used to. - `src/go-limits-data.ts` — the Go plan allowances, per model and per tier, **generated** from the vendor's own page by `scripts/generate-go-limits.ts`; never hand-edit it, and regenerate with `pnpm run limits:shim -- --write`. The scheduled freshness job is what notices when the page moves, which it did: the docs went under `/v2/docs/console/` and serve HTML now, so the generator reads a table off that page rather than the markdown it used to. - `scripts/freshness-scope.ts` — which of CI's three freshness checks (`catalog:shim`, `limits:shim`, `cli:version`) a pull request is GATED on: the ones whose inputs (the check's script and the local modules it loads, walked from the source as Node loads it — types erased by Node's own stripper, specifiers read by `es-module-lexer` — plus `src/identity.ts` for the CLI version; never the generated files) its own diff touches. The rest only warn, because vendor drift is not the pull request's regression: for the two generated tables the nightly run regenerates them and proposes the refresh, and for the CLI version it warns and the bump is a human pull request, which then gates. The gates fail closed: each runs unless the scope step answered `report`. The CLI version's offline half — every copy in the tree agrees with the pin, `cli:version -- --copies` — gates every pull request, since it skips the generated tables and only the repository can break it. `test/freshness-scope.test.ts` walks the real generators, runs the scope step's own shell and the CLI it calls, and evaluates the workflow's step conditions. -- `src/responses-routes.ts` · `src/responses-provider.ts` — the protocol table and the in-process mount of the host's `llm-pi-ai`. +- `src/host/route-claim.ts` · `src/host/plane-provider.ts` · `src/host/describe-graft.ts` · `src/host/host-modules.ts` · `src/host/google-levels.ts` — the plugin owns the plane routes (D13). The route claim prepends an `internal/config` listener that hands `llm-pi-ai`'s fiber a copy of its configuration without `opencode`/`opencode-go`, validates a Settings edit to a plane with pi-ai's own checks, and registers the plane adapter for exactly the declared planes through `registerAdapter`/`handle.replace`; on dispose it hands the planes back by restarting pi-ai (never on a reload). `plane-provider.ts` is `PlaneAdapter`, a subclass of the host's `PiAiAdapter` over pi's own `createProvider` and four protocol implementations, with the credential seams that mirror pi-ai's (D20). `describe-graft.ts` is the one read-only `settings.describe` wrapper that keeps Settings showing the planes (D18). `host-modules.ts` is where the host's own modules are found — pi-ai's on its loader entry, pi, dsh-llm and the loader from the wrapper's own tree — behind the replaceable seam in `src/cordis-context.ts` (`replaceHostModuleSource`), which is how tier A runs the claim. `google-levels.ts` wraps pi's Google `streamSimple` for the ids the catalog describes by thinking level (D16, `usesCatalogThinkingLevels`), builds each such call from pi's public exports loaded through that same seam, hands every other id (and any id pi's own pattern already serves by level) to pi, and is held at 100%; a profile that declares Off for such a model still gets pi's own disable (`thinkingBudget: 0`), which a model that cannot turn thinking off refuses. - `src/purge-service.ts` · `src/purge-contract.ts` — the card's "purge deprecated models" action, and the shape it answers with. It **rewrites the profile**, deleting the declared model entries the live catalog has retired, through `configEditor` (the same service the settings UI writes with). That is deliberate: a read-time filter would leave the declaration in place, and then the only way to explain a missing row is to find the filter. One source of truth, and it is the configuration. - `src/session-cost.ts` · `src/cordis-context.ts` · `src/debug.ts` — per-turn accounting, the typed host interfaces, and stream debug logging. -- `src/host/patch-method.ts` — `patchMethod`, the one way to wrap a method on a host object. A dispose restores only while its wrapper is still the current value (otherwise it becomes a pass-through under whatever wrapped on top), and deletes the own property when the original was inherited rather than assigning it back; a second install of the same site wraps the unpatched method instead of the first install. Identity is read from own descriptors, never from a property read, because cordis's traceable proxy answers a fresh proxy for every method read (`docs/engineering-notes.md`). `rewrapOnAdaptersUpdated` reinstalls a site on `llm/adapters-updated`, for a site that must follow the registry; none uses it yet. Every instance patch in `src/` goes through this module — today the four sites in `models-discovery.ts` — and `test/reload.test.ts` runs apply A, apply B, dispose A, dispose B on each. Two tripwires hold the rule. `test/architecture.test.ts` is textual: anywhere `apply()` reaches but the helper, it fails on a `Reflect.set`/`defineProperty`/`deleteProperty`/`setPrototypeOf`, an `Object.assign`/`defineProperty`/`defineProperties`/`setPrototypeOf` onto `llm`, an adapter, `settings` or `globalThis` (bare or through a property path such as `registration?.adapter`; a prototype swap shadows a method without leaving an own property), a dotted or bracketed assignment (through `?.` or `!` too) onto the first three, and `globalThis.x =` (the global `fetch` patch is the one named exemption, until P6); a write through a local alias or a cast receiver escapes it. The `apply()`-twice reload case is behavioural: it requires the helper's record on every own function `apply()` leaves on the stand-in's `llm` and adapter, and P-R1 adds `settings` to the stand-in together with its site. -- `src/pi-provider.ts` — the per-protocol base URLs, the pi model object, and the gateway headers. Every value here is measured; see `docs/redirect-planes.md`. -- `src/attachment-services.ts` — optional attachment and filesystem services for Google image requests, captured through Cordis child scopes. -- `src/google-bridge.ts` — the Google models, served through the plugin's own adapter on the real `llm` service. It is the ONLY module that imports pi at runtime, and its route is `opencode-google` so the `google` name stays free for a real deployment. +- `src/host/patch-method.ts` — `patchMethod`, the one way to wrap a method on a host object. A dispose restores only while its wrapper is still the current value (otherwise it becomes a pass-through under whatever wrapped on top), and deletes the own property when the original was inherited rather than assigning it back; a second install of the same site wraps the unpatched method instead of the first install. Identity is read from own descriptors, never from a property read, because cordis's traceable proxy answers a fresh proxy for every method read (`docs/engineering-notes.md`). `rewrapOnAdaptersUpdated` reinstalls a site on `llm/adapters-updated`, for a site that must follow the registry; none uses it yet. Every instance patch in `src/` goes through this module — today two sites: model discovery (`models-discovery.ts`) and the `settings.describe` graft (`host/describe-graft.ts`) — and `test/reload.test.ts` runs apply A, apply B, dispose A, dispose B on each. Two tripwires hold the rule. `test/architecture.test.ts` is textual: anywhere `apply()` reaches but the helper, it fails on a `Reflect.set`/`defineProperty`/`deleteProperty`/`setPrototypeOf`, an `Object.assign`/`defineProperty`/`defineProperties`/`setPrototypeOf` onto `llm`, an adapter, `settings` or `globalThis` (bare or through a property path such as `registration?.adapter`; a prototype swap shadows a method without leaving an own property), a dotted or bracketed assignment (through `?.` or `!` too) onto the first three, and `globalThis.x =` (the global `fetch` patch is the one named exemption, until P6); a write through a local alias or a cast receiver escapes it. The `apply()`-twice reload case is behavioural: it requires the helper's record on every own function `apply()` leaves on the stand-in's `llm` and `settings`. +- `src/utils/protocols.ts` · `src/utils/model-metadata.ts` · `src/utils/plane-model.ts` · `src/utils/plane-profile.ts` — the plane's pure decisions, each held at 100% coverage: the SDK → protocol table and the per-protocol base tables (every value measured; see `docs/redirect-planes.md`), the reasoning efforts a model offers (D1), the pi model object one declared model becomes (protocol from the catalog, D16; metadata from the user's row first, D17), and the profile one plane entry becomes, with pi-ai's own defaults and its own messages for every malformed field, never throwing. +- `src/attachment-services.ts` — optional attachment and filesystem services for the plane adapter's image requests, captured through Cordis child scopes. - `src/opencode-paths.ts` — where an OpenCode installation keeps its files, ported from `~/dev/opencode`'s `global-roots.ts` (the directory) and `database-path.ts` (the filename). It takes `home`, `env` and `channel` as parameters and reads neither the environment nor the filesystem, which is what makes the Windows and Linux layouts testable from here. Two facts it encodes that are easy to get wrong: the directory is XDG on EVERY platform — macOS and Windows use `~/.local/share` too, and `os.homedir()` is the only platform-dependent call — and the FILENAME is not constant, being `$OPENCODE_DB`, else `opencode.db` on a stable channel, else `opencode-.db`. The database sits under `data` while the config sits under `config`, so one root cannot serve both. It is the foundation of the opt-in reader: `go-discovery.ts`'s `resolveOpencodeStoreKey` is what imports it, and nothing else does. - `src/opencode-store.ts` — reading the credentials an OpenCode installation already holds, through `node:sqlite`. Two rules shape it: nothing throws (a missing file, a foreign schema and `:memory:` all answer `undefined`), and a value never leaves the return value — no log line, no error message, no client bundle. `node:sqlite` is host-only, which is what keeps the module on the host side of the bundle rule. It is reached only through `resolveOpencodeStoreKey`, which is the last step of every key-source policy and is off unless `readOpencodeStore` is on. Its second reader, `inspectOpencodeStore`, answers the _other_ question — "is there a credential here, and whose is it?" — with a sanitized summary (email, org, kind, expiry) and never the token, which is what lets the settings card show the evidence beside the permission. - `src/opencode-store-contract.ts` · `src/opencode-store-service.ts` — the inspection seam: the sanitized summary type both sides share, and the host service that produces it. The contract is pure data (no `node:`, no context), so the client bundle may import it; the service is the half that touches `node:sqlite` and the filesystem, and it rewrites the store path to `~`-relative before answering — a home directory is a fact about the machine, not about the plugin. `inspect` is declared through the `Object.defineProperty` marker rather than `@Remote`, for the reason `usage.ts` documents. @@ -114,7 +119,7 @@ vp test run # unit suite (refuses to start below engines.node) pnpm run test:shuffle # the unit suite in random order; CI runs it on Node 24 pnpm run test:mock # gateway mock suite: offline, no key, runs in CI pnpm run test:e2e:host # opt-in: the published host binary with the probe (CONTRIBUTING, "The e2e-host job") -vp test run --coverage # with the coverage ratchet (95/90/93/95) +vp test run --coverage # with the coverage ratchet (thresholds in vite.config.ts) vp check # format, lint, types vp fmt --write # rewrite formatting pnpm run build # build lib/ (host bundle + client bundle) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a576fc13..b51ffd05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,9 +44,9 @@ 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 × the legacy (`/zen(/go)/v1`) and modern (`/inference(/go)/…`) endpoint families, 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 sixteen cells have a model and were sent, that every answer is readable, and that no cell the plugin dispatches to today with the default endpoint (`auto`, an alias of `modern`; `TODAY_DISPATCH`: completions on the LEGACY family, Responses, Messages and Google on the modern one) is gone — nor the modern completions cells P-R1 moves completions to (`NEXT_DISPATCH`, a labelled forward guard). The other legacy verdicts are recorded for P-R2 — among them the cells an `endpoint: legacy` user dispatches to (Responses and Messages on both planes, and Zen's Google), which are recorded, not asserted; Go's Google stays on `/inference/go/google/v1beta` under every family (`GO_GOOGLE_BASE_URL`), the modern cell `TODAY_DISPATCH` already asserts. The cell table, both sets and the classifier 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. -- `protocol-routing.e2e.ts` — asks the gateway which endpoint serves each model's wire format: keyless for the free classes, keyed (and skipped without a secret) for paid ones. -- `dispatch-levels.e2e.ts` — every thinking level the picker offers is one pi's own rule accepts, swept over the whole shipped catalog; no network. +- `keyless-matrix.e2e.ts` — the keyless 401-vs-404 matrix: two planes × four protocols × the legacy (`/zen(/go)/v1`) and modern (`/inference(/go)/…`) endpoint families, 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 sixteen cells have a model and were sent, that every answer is readable, 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, since P-R1 moved completions there) is gone. The legacy verdicts are recorded for P-R2 — among them every cell an `endpoint: legacy` user dispatches to (completions, Responses and Messages on both planes, and Zen's Google), which are recorded, not asserted; Go's Google stays on `/inference/go/google/v1beta` under every family (`GO_GOOGLE_BASE_URL`), the modern cell `TODAY_DISPATCH` already asserts. The cell table, both sets and the classifier 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. +- `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) for paid ones. Keyed probes of the legacy family per protocol are P-R2's record. Its last block sends paid models through the plane adapter on a tier-A host: each protocol walks up to three candidates (`walkPerProtocol`, `test/support/live-walk.ts`, unit-tested in `test/live-walk.test.ts`), moving past an entitlement answer or a withdrawal that a raw probe of the same id confirms (`test/support/live-probe.ts`), and a protocol no candidate reached skips the case by name rather than passing. +- `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 sends one toggle model per plane and protocol through the plane adapter on a tier-A host with the picker's "On" effort, walking up to three toggle candidates per protocol the same way, and skips without the plane's key or, naming it, when a protocol had no entitled, still-served candidate. - `refusal-classification.e2e.ts` — the refusal classifier's verdicts on the gateway's bytes over a real `node:http` socket. - `host-binary.e2e.ts` — the published host binary; see [The real-host harness](#the-real-host-harness). It skips unless `DSH_HOST_E2E=1`. @@ -56,29 +56,29 @@ What the main files are for: 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, this plugin is a **degraded** one: it finds pi-ai through the loader's entries, so with no loader it patches fetch but mounts no hidden route and no Google bridge — the bridge's adapter class comes from the loader's pi-ai entry too (`host-harness` pins both: the stand-down line, and `loadPiAiAdapterHost` answering `undefined`). Characterize the real plugin in tier B. -- **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`). Its rows carry **package names**, resolved by a stub module loader, not `cordis:` builtins: the plugin finds pi-ai's entry by `options.name === "@deepseek-ai/dsh-llm-pi-ai"` (`src/responses-provider.ts`), so a builtin-named row would silently drop today's hidden-route mount and the Google bridge. 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"`. +- **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. 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`). 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. -- **Give tier-B cases 30 s**, and wait with a deadline (`waitForRoutes`, 10 s by default, so a route that never registers fails by name inside the hook's budget), never a fixed sleep: the plugin mounts its hidden routes asynchronously. +- **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. -**Tags.** Every characterization row is titled `[BASELINE]` (must survive every phase), `[KNOWN_DEFECT]` (wrong today; a phase fixes it) or `[INTENDED_CHANGE]` (right today; a phase changes it on purpose). A phase that flips a row names it in its PR body; nothing else may move, except that P-R2's measured endpoint-family flip, if it happens, may move the family part of a `[BASELINE]` wire facet, re-tagging each one it moves and naming it in its PR body. The per-model rows live in `S0_TODAY` in `host.ts`, which the binary e2e reads too, and each model's three facets — model info, the call-config verdict, the wire — carry their own tag there: `host-characterization` generates one test per model and facet titled with that tag, in each apply order (`todayRowsFor(order)` carries the pi-ai-first table's own tags, where Go Gemini's call config is already the behaviour P-R1 keeps), so the tags in `S0_TODAY` are what govern which entries a phase may flip. The one facet that depends on the apply order today (Go `gemini-4-argon-contributor`'s call config) is pinned by its own `[KNOWN_DEFECT]` case, outside the booted blocks; the phase that flips that row also drops `todayRowsFor`'s pi-ai-first override and turns that case into a `[BASELINE]` "no facet depends on the apply order" check, in the same diff. The fixture is S0 plus one declared id no catalog knows (`CHARACTERIZED_PROFILE`); the contract tests keep plain S0. +**Tags.** Every characterization row is titled `[BASELINE]` (must survive every phase), `[KNOWN_DEFECT]` (wrong today; a phase fixes it) or `[INTENDED_CHANGE]` (right today; a phase changes it on purpose). A phase that flips a row names it in its PR body; nothing else may move, except that P-R2's measured endpoint-family flip, if it happens, may move the family part of a `[BASELINE]` wire facet, re-tagging each one it moves and naming it in its PR body. The per-model rows live in `S0_TODAY` in `host.ts`, which the binary e2e reads too, and each model's three facets — model info, the call-config verdict, the wire — carry their own tag there: `host-characterization` generates one test per model and facet titled with that tag, in each apply order (`todayRowsFor(order)` carries that order's tags), so the tags in `S0_TODAY` are what govern which entries a phase may flip. P-T1b pinned the one facet that depended on the apply order (Go Gemini's call config) with its own `[KNOWN_DEFECT]` case outside the booted blocks; P-R1 flipped that row, dropped `todayRowsFor`'s pi-ai-first override and turned the case into the `[BASELINE]` "no facet depends on the apply order" check, in the same diff. The fixture is S0 plus one declared id no catalog knows (`CHARACTERIZED_PROFILE`); the contract tests keep plain S0. -**A catalog refresh can touch the rows.** `S0_TODAY`'s context windows and effort lists are copied from the generated `src/catalog-data.ts`, which the nightly refresh rewrites. A fixture-sanity case compares each row's context window and effort ids to the bundled catalog's derivation and names the model that moved; when it fails on a refresh pull request, update the row in `S0_TODAY`. A phase that moves a non-`BASELINE` `info` facet off the catalog on purpose (the user's context window, a toggle model's Off/On) adds that row's key to the case's `INFO_NOT_FROM_CATALOG` set in the same diff; the case refuses to exempt a `BASELINE` facet. The `e2e-host` job's scope includes `test/support/host.ts`, so the binary e2e runs on that pull request too and checks the edited row against the published binary. +**A catalog refresh can touch the rows.** `S0_TODAY`'s context windows and effort lists are copied from the generated `src/catalog-data.ts`, which the nightly refresh rewrites. A fixture-sanity case derives each row's context window and effort ids the way the plane adapter does (D17: the profile row's `contextWindow`, else the catalog; the efforts from `reasoningFor`) and names the model that moved; when it fails on a refresh pull request, update the row in `S0_TODAY`. A phase that moves an `info` facet off that derivation on purpose adds that row's key to the case's `INFO_NOT_FROM_CATALOG` set in the same diff; the case refuses to exempt a `BASELINE` facet, and since P-R1 the set is empty. The `e2e-host` job's scope includes `test/support/host.ts`, so the binary e2e runs on that pull request too and checks the edited row against the published binary. -**The apply order is a race on the real host.** Tier B fixes it; the published binary does not. The rows depend on whether the plugin's adapter decoration wrapped pi-ai's plane adapter, so the probe records that and the order it implies (`inferredOrder` — inferred, not observed: a pi-ai restart after the plugin applied would read as plugin-first), and the binary e2e picks its rows with `todayRowsFor(inferredOrder)` instead of pinning one. +**The apply order is a race on the real host.** Tier B fixes it; the published binary does not. Since P-R1 the rows no longer depend on it — the route claim answers the same in both orders, which the characterization asserts by running both — so `todayRowsFor` answers `S0_TODAY.rows` for either order, and the probe waits until both planes are owned by the plugin's `PlaneAdapter` (`planesSettled`) before it measures. ### The `e2e-host` job -`.github/workflows/ci.yml`'s `e2e-host` job installs the published `@deepseek-ai/dsh` (at `HOST_VERSIONS.dsh`) into a throwaway prefix, builds this checkout, and runs `test/e2e/host-binary.e2e.ts`. That file creates a temporary HOME whose profile links this checkout and the probe bundle (`test/e2e/host/`) through relative `link:` paths, stores dummy keys in the temporary home's credentials document (`$DSH_HOME/.credentials.yaml`, shared by every profile under that home), boots `dsh --profile e2e --no-open --port 0`, and reads what `test/e2e/host/probe-plugin.mjs` measured. It asserts today's rows; P-R1 flips them. +`.github/workflows/ci.yml`'s `e2e-host` job installs the published `@deepseek-ai/dsh` (at `HOST_VERSIONS.dsh`) into a throwaway prefix, builds this checkout, and runs `test/e2e/host-binary.e2e.ts`. That file creates a temporary HOME whose profile links this checkout and the probe bundle (`test/e2e/host/`) through relative `link:` paths, stores dummy keys in the temporary home's credentials document (`$DSH_HOME/.credentials.yaml`, shared by every profile under that home), boots `dsh --profile e2e --no-open --port 0`, and reads what `test/e2e/host/probe-plugin.mjs` measured. It asserts the rows `S0_TODAY` records, which P-R1 flipped to the plugin owning both planes. -- **Cells.** `pinned` forces pi, cordis and the loader to `HOST_VERSIONS` with npm `overrides` (the published host declares cordis and the loader as tilde ranges), applies the harness patch to pi with `patch -p1`, asserts all three installed versions (a cached prefix included), then asserts every row. `floating` takes the pi npm resolves for the published host. It skips the rows that depend on which ids pi describes (the keyed listing and the per-model rows) and still asserts the save rows. Those assume the floating pi lacks at least one of `ZEN_UNDESCRIBED` and still describes every other declared Zen id, so a pi that learns all of them or retires a declared id turns them red by design: triage a red floating run as a pi change first. Its stored-gap save row asserts only that SOME undescribed id blocks the save, not which one; its probe JSON is the record. -- **Triggers.** Dispatch and the nightly run take both cells. A pull request takes `pinned`, and only when it touches `src/host/**`, `src/lifecycle.ts`, `src/identity.ts`, the modules that install the host patches and mounts whose behaviour depends on the real loader and apply order, until P-R1 moves them under `src/host/` (`src/responses-provider.ts`, `src/responses-routes.ts`, `src/models-discovery.ts`, `src/google-bridge.ts`, `src/fetch-patch.ts`, `src/pi-provider.ts`, `src/stream-hook.ts`; every other row producer, such as `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. +- **Cells.** `pinned` forces pi, cordis and the loader to `HOST_VERSIONS` with npm `overrides` (the published host declares cordis and the loader as tilde ranges), applies the harness patch to pi with `patch -p1`, asserts all three installed versions (a cached prefix included), then asserts every row. `floating` takes the pi npm resolves for the published host. It skips only the per-model rows (a newer pi's own entry can move a model's metadata); 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. +- **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. 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: @@ -100,7 +100,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Host (`lib/index.mjs`) — restart required.** The base bundle ships `hmr root: []` (config watches only, `**/node_modules` ignored) and the loader caches ESM imports per process, so a linked package is never re-imported after boot. After every `pnpm run build`, restart `dsh web` — otherwise the profile silently keeps running the module from boot time. Symptom of a stale host: the plugin row is active but the settings card shows "This plugin is not loaded, so it cannot be configured." — `dsh-settings` is filtering on the old module's `Config` schema. - **Client (`lib/client.js`) — refresh suffices.** The client bundle is re-served from disk on every page load; a browser refresh picks it up. -- **Verify after a restart:** the Plugins page shows the **OpenCode Patch** card, and its configure view renders all 17 fields (no unavailable line). `node --experimental-strip-types scripts/check.ts` catches a stale `lib/` before you do (`src/` newer than `lib/`). +- **Verify after a restart:** the Plugins page shows the **OpenCode Patch** card, and its configure view renders every field of `Config.dict` in `src/config.ts` (no unavailable line). `node --experimental-strip-types scripts/check.ts` catches a stale `lib/` before you do (`src/` newer than `lib/`). ## Code conventions @@ -129,7 +129,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. `responses-provider.ts` and `responses-routes.ts` carry their own per-file floors: the mount has four host contracts to survive, and a well-covered average must not be able to hide it. `host/patch-method.ts` carries a 100% floor, because it is the one way to patch a host method and each of its branches is a reload case. +`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`, the four pure plane modules (`utils/protocols.ts`, `utils/model-metadata.ts`, `utils/plane-model.ts`, `utils/plane-profile.ts`) and the Google level wrapper (`host/google-levels.ts`) at 100%, because each branch is a reload case, a protocol, a precedence, one of pi-ai's checks or one step of pi's own level path; `host/route-claim.ts`, `host/plane-provider.ts`, `host/describe-graft.ts` and `host/host-modules.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. ## The freshness checks in CI diff --git a/README.md b/README.md index a3560cb5..e85ec31b 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ **What is left once those work is a different problem, and it is not about the account.** OpenCode serves its models on **four different API shapes**, while DSH picks a transport from the provider row — one route can speak one shape. So a model served on Responses, Messages or Mistral answers `500` the moment the basics are restored, which is why this plugin routes per **model** rather than per provider. -**What this does.** It restores every missing protocol element at the network layer — **strictly for OpenCode routes** (`opencode` / `opencode-go` / `opencode-responses` / `opencode-anthropic` / `opencode-google`). All other traffic (DeepSeek, OpenAI, Anthropic, GitHub) passes through untouched. +**What this does.** It serves the two OpenCode routes itself — `opencode` and `opencode-go`, every model on the protocol the catalog names — and restores every missing protocol element at the network layer, **strictly for those routes**. All other traffic (DeepSeek, OpenAI, Anthropic, GitHub) passes through untouched. **What you get.** A picker listing the whole OpenCode catalog with real names, prices and context windows; every model sent to the endpoint it is actually served on; and a live meter for the quota or the spend — no configuration. @@ -59,7 +59,7 @@ - 🧰 Free-tier `read` + `bash` tool-schema fallback, so a free-tier body carries the tool definitions its endpoint requires — one cause of a `403` removed, and not the whole of it: the gateway's gate is not in the request, measured - 📇 OpenCode-catalog-backed (`models.opencode.ai`, the registry models.dev publishes) with offline shims and background SWR refresh — names, context windows, prices - ⭕ Live dual-mode composer meter — Go quota ring (5-hour / weekly / monthly) or Zen pay-as-you-go pill, plus session spend and model rate -- 🕵️ Strict credential isolation — Zen keys (`oc_sk_…`) never query the Go quota endpoint +- 🕵️ Go meter credential guard — the Go quota endpoint is never sent an `OPENCODE_API_KEY` reference; it reads the Go entry's `apiKeyEnv` (else `OPENCODE_GO_API_KEY`) or a key a Go-plane request carried **Contents** @@ -68,10 +68,12 @@ 3. [Supported Models & Protocols](#-supported-models--multi-protocol-routing) 4. [The Interface](#-the-interface) 5. [Configuration Reference](#-configuration-reference) -6. [Troubleshooting](#-troubleshooting) -7. [Compatibility & Verification](#-compatibility--verification) -8. [Deep Dive](./docs/deep-dive.md) — the protocol internals, out of this README -9. [Attribution & License](#-attribution--license) +6. [Removing or Disabling the Plugin](#removing-or-disabling-the-plugin) +7. [Troubleshooting](#-troubleshooting) +8. [Changed in 1.x](#changed-in-1x) +9. [Compatibility & Verification](#-compatibility--verification) +10. [Deep Dive](./docs/deep-dive.md) — the protocol internals, out of this README +11. [Attribution & License](#-attribution--license) --- @@ -103,7 +105,7 @@ The package is `dsh-opencode-patch` under every name it is used by: the dependen } ``` -**3. Add a credential** so the quota meter can resolve a key — store `OPENCODE_GO_API_KEY` (Go subscription, `sk-…`) and/or `OPENCODE_API_KEY` (Zen pay-as-you-go, `oc_sk_…`) in DSH Credentials or your environment. +**3. Add a credential** so the quota meter can resolve a key — store `OPENCODE_GO_API_KEY` (Go subscription, `sk-…`) and/or `OPENCODE_API_KEY` (Zen pay-as-you-go, `oc_sk_…`) in DSH Credentials or your environment. **On the Go plane, name it:** give the `opencode-go` entry `apiKeyEnv: OPENCODE_GO_API_KEY`. A plane entry without `apiKeyEnv` authenticates by pi's own lookup, which reads `OPENCODE_API_KEY` on both planes, so a Go entry without one sends the Zen key. **4. Restart `dsh web`.** The host bundle is only imported at boot (`hmr root: []`), so a restart is what loads `lib/index.mjs`; client UI changes (`lib/client.js`) only need a browser refresh. @@ -121,7 +123,7 @@ Done — the **OpenCode Patch** card appears under _Settings → Plugins_, and t | Subagents lose conversation context | **Parent session tracking** (`x-opencode-parent-session-id`, `x-parent-session-id`) | | Auto Review calls fail with `TRANSPORT: Connection error` | **Fallback session turn capture** preserving turn state across eval calls | | Quotas and balances are invisible | **Live dual-mode composer meter** showing Go quota or Zen pay-as-you-go | -| Zen keys cause `403 EntitlementError` on Go usage | **Strict credential isolation** keeping Zen keys away from the Go endpoint | +| Zen keys cause `403 EntitlementError` on Go usage | **The meter never reads an `OPENCODE_API_KEY` reference for Go.** It uses, in order, a literal `apiKey` on the Go entry, a key captured on a Go-plane request, the Go entry's `apiKeyEnv` (an `OPENCODE_API_KEY` reference is read as `OPENCODE_GO_API_KEY`), any captured Go-plane key, then the opt-in store. A Go turn carries the key its entry names; an entry that names none sends pi's `OPENCODE_API_KEY`, the request patch files that key as a Go key, and from the first Go turn the meter sends it to the Go usage endpoint and gets this `403`. Name `apiKeyEnv: OPENCODE_GO_API_KEY` on the Go entry so the turn and the meter agree, or set Credential Source to **Declared key first** in Settings | | Projects share a single `"global"` telemetry bucket | **Dynamic workspace attribution** resolved from the active `session.header.cwd` | | Gateway `/models` returns a truncated, unnamed list | **Catalog enrichment** (`models.opencode.ai`) with display names, context windows and prices | @@ -129,71 +131,51 @@ Done — the **OpenCode Patch** card appears under _Settings → Plugins_, and t ## 🧭 Supported Models & Multi-Protocol Routing -**Both endpoints, and both credential shapes.** OpenCode's catalog defines an endpoint at two levels: a **provider default** — `https://opencode.ai/zen/v1` for Zen, `https://opencode.ai/zen/go/v1` for Go — and a per-model **inference** endpoint that overrides it when the catalog sets one (the CLI reads `model.api.url`, falling back to the provider default). The plugin pins neither: it matches the `opencode.ai/zen` marker, so a model served from the default and a model served from its own endpoint are both patched. +**One route per plane, every protocol on it.** The plugin serves `opencode` (Zen) and `opencode-go` (Go) through its own adapter — a subclass of the host's own `llm-pi-ai` adapter, registered on the host's `llm` service with the host's own `registerAdapter`. `llm-pi-ai` keeps every other route you declare. Each model is dispatched on the protocol its catalog row names, so there is no second route to declare, mount or hide. -Those endpoints authenticate in **two ways**, because they speak three wire shapes: the OpenAI planes send `Authorization: Bearer `, the Anthropic plane sends `x-api-key: `. The plugin reads **both** — `Authorization` first, then `x-api-key` / `api-key` — so a credential is captured whichever plane carried it, and the Go quota read still finds a key. +The gateway serves four protocol families, and **OpenAI alone is two of them**: Chat Completions and Responses are different wire formats, not two names for one thing. A model that needs Responses does not work on the completions endpoint, and the gateway does not fall back — it answers `500`. All four are served, each with pi's own implementation of that protocol. -The gateway serves four protocol families, and **OpenAI alone is two of them**: Chat Completions and Responses are different wire formats, not two names for one thing. A model that needs Responses does not work on the completions endpoint, and the gateway does not fall back — it answers `500`. +**Two endpoint families.** OpenCode answers on two separately entitled families. **Modern** — the default, and what `endpoint: auto` means — is the per-protocol inference family the catalog names: `https://opencode.ai/inference/{openai,anthropic,google}/…` on Zen and `https://opencode.ai/inference/go/{openai,anthropic,google}/…` on Go. **Legacy** (`endpoint: legacy`) is the older single base per plane, `https://opencode.ai/zen/v1` and `https://opencode.ai/zen/go/v1`. A `baseURL` on the plane's own entry picks the family by its path, and any other host is used as written. -DSH speaks three of the four families itself. The fourth — Google Generative AI — is served by **this patch's own adapter**, registered on the host's `llm` service under the route `opencode-google`, so all four are routed. See the table below. +Those endpoints authenticate in **three ways**, because they speak three wire shapes: the OpenAI planes send `Authorization: Bearer `, the Anthropic plane `x-api-key: `, the Google plane `x-goog-api-key: `. ### 1. OpenCode Zen (`provider: opencode`) — pay-as-you-go & free tier -- **Anthropic Messages** (`https://opencode.ai/zen/v1/messages`): `claude-sonnet-4-5`, `claude-opus-4-7`, `claude-haiku-4-5`, `qwen3.8-flash` -- **OpenAI Responses** (`https://opencode.ai/zen/v1/responses`): `gpt-5.4`, `gpt-5.2`, `gpt-5.1-codex-max`, `muse-spark-1.3`, `space-bunny-free`, and the free-tier `muse-spark-1.3-contributor-free` -- **OpenAI Chat Completions** (`https://opencode.ai/zen/v1/chat/completions`): `deepseek-v4.1-flash`, `kimi-k2.5`, `kimi-k3`, `minimax-m2.5`, `glm-5.2`, plus free-tier `nemotron-3-ultra-free`, `ling-3.0-flash-fin-free`, `mimo-v2.6-flash-free` -- **Google Generative AI** (`https://opencode.ai/zen/v1/models/*:streamGenerateContent`), served by this patch's adapter as `opencode-google`: `gemini-3.8-flash`, `gemini-3.1-pro`, `gemini-3.5-flash-lite` +- **Anthropic Messages** (`https://opencode.ai/inference/anthropic/v1/messages`): `claude-sonnet-4-5`, `claude-opus-4-7`, `claude-haiku-4-5`, `qwen3.8-flash` +- **OpenAI Responses** (`https://opencode.ai/inference/openai/v1/responses`): `gpt-5.4`, `gpt-5.2`, `gpt-5.1-codex-max`, `muse-spark-1.3`, `space-bunny-free`, and the free-tier `muse-spark-1.3-contributor-free` +- **OpenAI Chat Completions** (`https://opencode.ai/inference/openai/v1/chat/completions`): `deepseek-v4.1-flash`, `kimi-k2.5`, `kimi-k3`, `minimax-m2.5`, `glm-5.2`, plus free-tier `nemotron-3-ultra-free`, `ling-3.0-flash-fin-free`, `mimo-v2.6-flash-free` +- **Google Generative AI** (`https://opencode.ai/inference/google/v1beta/models/*:streamGenerateContent`): `gemini-3.8-flash`, `gemini-3.1-pro`, `gemini-3.5-flash-lite` ### 2. OpenCode Go (`provider: opencode-go`) — subscription quota -- **Google Generative AI** (`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`): `gemini-4-argon`, served by the internal `opencode-go-google` adapter. Add the model under your existing `opencode-go` provider; the bridge inherits that provider's API key reference (default `OPENCODE_GO_API_KEY`). It supports image attachments and signed tool-call replay. Only configured models are mounted; access still depends on your Go plan. This endpoint is also used with `endpoint: legacy`, because Go has no legacy Google endpoint. - -- **OpenAI Chat Completions** (`https://opencode.ai/zen/go/v1/chat/completions`): `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `iceman-contributor-free`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` -- **OpenAI Responses** (`https://opencode.ai/zen/go/v1/responses`): `gpt-5.6-luna`, `gpt-6-luna`, `grok-4.5`, `grok-4.6`, `grok-4.7`, `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor` -- **Anthropic Messages** (`https://opencode.ai/zen/go/v1/messages`): `claude-haiku-5-5`, `minimax-m2.7`, `minimax-m3`, `qwen3.7-plus`, `qwen3.8-flash`, `qwen3.8-max` +- **OpenAI Chat Completions** (`https://opencode.ai/inference/go/openai/v1/chat/completions`): `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `iceman-contributor-free`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` +- **OpenAI Responses** (`https://opencode.ai/inference/go/openai/v1/responses`): `gpt-5.6-luna`, `gpt-6-luna`, `grok-4.5`, `grok-4.6`, `grok-4.7`, `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor` +- **Anthropic Messages** (`https://opencode.ai/inference/go/anthropic/v1/messages`): `claude-haiku-5-5`, `minimax-m2.7`, `minimax-m3`, `qwen3.7-plus`, `qwen3.8-flash`, `qwen3.8-max` +- **Google Generative AI** (`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`): `gemini-4-argon`, with image attachments and signed tool-call replay. Go has no legacy Google endpoint, so this one base serves both families. - Monitored by the live 3-window quota meter (5-hour rolling, weekly, monthly). The full set ships in the bundled catalog — see [Authoritative Model Catalogs](#7-authoritative-model-catalogs-dual-local-shims--real-time-swr-updates). ### 3. How a model's protocol is chosen — and why you configure nothing Zen's provider-level SDK is `@ai-sdk/openai-compatible`. models.dev names a **different** SDK per model only when that model needs one, so the presence of `provider.npm` is the signal — and it is what decides the wire protocol. Nothing is matched by hand: -| models.dev `provider.npm` | models | protocol | served from | -| :-- | --: | :-- | :-- | -| _(absent)_ | 24 | OpenAI Chat Completions | the route you configured | -| `@ai-sdk/openai` | 31 | OpenAI Responses | `opencode-responses` | -| `@ai-sdk/anthropic` | 17 | Anthropic Messages | `opencode-anthropic` | -| `@ai-sdk/google` | 8 | Google Generative AI | `opencode-google` | -| `@ai-sdk/mistral` | 1 | _(a dialect this plugin does not serve)_ | **not offered** | +| models.dev `provider.npm` | models | protocol | +| :-- | --: | :-- | +| _(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 **81** active `opencode` models in `models.opencode.ai` as of 2026-10-27 — 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`. -The patch **keeps those two routes out of both the model picker and _Settings → Models_**. Three properties hold today, and one does not yet: - -- **You keep choosing.** The picker shows exactly the models you listed, and each one is matched to the right protocol automatically. OpenCode serves models on three shapes — OpenAI Chat Completions (the default), OpenAI Responses, and Anthropic Messages — and a model that needs one of the other two is dispatched to the route that speaks it. No second route to declare by hand. -- **You are never offered a model that cannot work.** Every one of the four families is reachable, so no model is dropped for want of a protocol: the `@ai-sdk/google` models are served through the patch's own adapter, and the three DSH already speaks are dispatched to the route that speaks them. That holds for models you list by hand as well — one the catalog does not describe is routed by the family its id names (`gemini-*` to the Generative AI path, `claude-*` to Messages), and if the route you put it on cannot serve that protocol it is not offered there at all, rather than answering `400 ModelProtocolUnsupported` when you pick it. -- **One credential, from wherever you already have one.** A routed model authenticates with the key you configured, resolved through the credentials service and never re-asked for — and if a route names no key at all, the installation's own OpenCode store answers, when the deployment opts into reading it. Your own configuration always wins: the order is the route's own reference, then the store, and nothing is consulted before the thing you declared. - -**The route, if you would rather declare it yourself:** `opencode-responses` — and `opencode-anthropic`, once you use an Anthropic-plane model — can live in your profile's `llm-pi-ai` `providers` block, listing the models it serves. Declaring it is a choice, not a requirement: - -```yaml -opencode-responses: - api: openai-responses - baseURL: https://opencode.ai/zen/v1 - headers: - authorization: Bearer unused # swapped for your key by the fetch patch - models: - - id: muse-spark-1.3-contributor-free - name: Muse Spark 1.3 Free - contextWindow: 1048576 - maxTokens: 131072 - input: [text, image] -``` - -**The plugin registers that route for you**, so the block above is optional. `responses-provider.ts` mounts the host's own `llm-pi-ai` in-process, isolated from the authorization and settings seams and behind an `llm` facade that forwards only the adapter registration — so the route lands without duplicating the catalog directory, the settings namespace, or a single sign-in flow. This is verified against the real harness, not a stub: the route registers, the host's 41 catalog entries and 41 sign-in flows are untouched, and unloading withdraws the route again. +Four properties follow: -The routes inherit **your** credential: each one names the same `apiKeyEnv` your `opencode` route already declares, so a custom key reference is never asked for twice. Routes your profile already declares are left alone — the plugin defers to your model list rather than overriding it. +- **You keep choosing.** Each plane serves exactly the models its `llm-pi-ai` entry lists — and the whole catalog when the entry lists none. Any id you save is accepted and served: one neither the catalog nor pi describes is sent as Chat Completions, unless the plane's entry names a route-level `api`, which applies to exactly those ids. +- **Your row wins.** A model's name, context window, output limit and input modalities come from your own model row first, then the catalog, then pi's installed data. Its reasoning efforts come from your row's `reasoningEfforts`, then the catalog. Its cost and its protocol are not row fields: the cost comes from the catalog, then pi, and the protocol from the catalog's SDK, then pi's entry. +- **A model whose reasoning is only on or off is offered as Off / On.** Off appears only where the model's protocol can actually turn reasoning off; a model with a real effort list offers exactly its levels, and a Chat Completions model whose list names `none` also offers Off, sent as `reasoning_effort: "none"` the way pi's own entries carry it. +- **One credential, by the host's own rule.** A plane authenticates the way `llm-pi-ai` would: the entry's `apiKeyEnv` through the credentials service (or the launch environment when no credentials service is mounted), a Sign-in record stored under `llm-pi-ai/opencode`, and otherwise pi's own `OPENCODE_API_KEY` lookup, which is the same variable on both planes: name `apiKeyEnv: OPENCODE_GO_API_KEY` on the Go entry. The installation's own OpenCode store is consulted only when no reference is named and the deployment opted in. A plane whose key does not resolve still lists its models, and the turn fails with `MISSING_CREDENTIAL` before any request is sent. -If your host has no loaded `llm-pi-ai` entry, the plugin registers nothing and says so in the log; declare the route in your profile in that case. +**Settings stays the host's.** The plane entries live in `llm-pi-ai`'s `providers` block exactly as before, and _Settings → Models_ reads and writes them there. The plugin keeps `llm-pi-ai` from serving the two planes by handing it a copy of its configuration without them, and validates a Settings edit to a plane with `llm-pi-ai`'s own checks, so a malformed entry is refused before it is written. ### 4. Execution modes covered @@ -246,7 +228,7 @@ Nine controls in four sections — the decisions a user actually makes. Everythi | **Gateway Requests** | Inject User-Agent | `on` | Restores the official OpenCode CLI `User-Agent` so Cloudflare WAF checks pass | | **Gateway Requests** | Inject Origin Headers | `on` | Injects `x-opencode-client` (and the origin header set) on gateway traffic | | **Gateway Requests** | Attach Workspace Project | `on` | Tags `x-opencode-project` with the active folder name; off omits the header | -| **Models & Free Tier** | Enrich model metadata from the OpenCode catalog | `on` | Fills catalog context limits, modalities and thinking levels into model info, and lets the gateway `/models` listing carry the full catalog. Does **not** add models to the picker — the picker shows declared models only; use the card's **Check catalog** row to declare more | +| **Models & Free Tier** | Enrich listings from the OpenCode catalog | `on` | Lets the gateway `/models` listing carry the full catalog, and offers it to Settings' model discovery. Does **not** add models to the picker — the picker shows declared models only; use the card's **Check catalog** row to declare more. A declared model's own info (context, modalities, thinking levels) follows your row and the catalog whatever this says | | **Models & Free Tier** | Inject Core Tools | `on` | Adds the `read` + `bash` schemas free-tier `/responses` bodies require | | **Quota Meter** | Enable Composer Usage Meter | `on` | Mounts the live meter in the composer dock — the Go quota ring, or the Zen session spend | | **Account & Endpoint** | API Endpoint | `auto` | Which endpoint family requests go to: **Automatic** (same as Modern) · **Legacy** (`zen/v1`, `zen/go/v1`) · **Modern** (per-protocol `inference/{openai,anthropic,google}/…`, `inference/go/…` on Go) | @@ -259,7 +241,7 @@ Two questions decide where a request goes, and the **Account & Endpoint** sectio **Which endpoint family.** The gateway answers on two families — the older `/zen/v1` (`/zen/go/v1` on Go), one base for every protocol, and the newer per-protocol `inference` family (`/inference/openai/v1`, `/inference/anthropic/v1`, `/inference/google/v1beta`, with `/inference/go/…` on Go) — and they are entitled **separately**: an account that works on one is not guaranteed to work on the other. `modern` sends each protocol to its own inference path; `auto`, the default, is an alias of `modern`; `legacy` pins the older family, which is what you want when your account only works there. -**Which credential wins.** `keySource` orders the sources the plugin can see — `auto` prefers a key you declared, then one captured from a live request, then the declared reference; `request` promotes a captured key first; `configured` does the reverse. Below it, `readOpencodeStore` lets the plugin read the credential OpenCode itself stored, as the **last** resort only. Both are `off`/`auto` by default, so nothing changes until you say so. +**Which credential wins.** A turn on a plane authenticates by the host's own rule (see [How a model's protocol is chosen](#3-how-a-models-protocol-is-chosen--and-why-you-configure-nothing)). For the quota meter and the request patch, `keySource` orders the sources the plugin can see — `auto` prefers a key you declared, then one captured from a live request, then the declared reference; `request` promotes a captured key first; `configured` does the reverse. Below it, `readOpencodeStore` lets the plugin read the credential OpenCode itself stored, as the **last** resort only. Both are `off`/`auto` by default, so nothing changes until you say so. Override-style knobs (literal strings, markers, route lists) are deliberately **config-only** so a default fits every documented setup — see [Configuration Reference](#-configuration-reference). @@ -364,7 +346,7 @@ These exist in the schema but render no control — each is a literal, a marker, | Knob | Default | Why it stays in config | | :-- | :-- | :-- | -| `providers` | `opencode`, `opencode-go`, `opencode-responses`, `opencode-anthropic` | Route ids to intercept; must cover every route this layer declares | +| `providers` | `opencode`, `opencode-go` | Route ids whose turns the plugin decorates; both planes are always included, so only a relay route needs adding | | `gatewayUrls` | `opencode.ai/zen` | URL substrings marking gateway traffic; only a mirror or relay changes them | | `userAgent` | empty (= canonical CLI UA) | Literal override; the default is what the gateway expects | | `originClient` | `cli` | Literal `x-opencode-client` value | @@ -380,14 +362,11 @@ These exist in the schema but render no control — each is a literal, a marker, - id: dsh-opencode-patch name: "dsh-opencode-patch" config: - providers: + providers: # both planes are always included - opencode - opencode-go - - opencode-responses - - opencode-anthropic gatewayUrls: - opencode.ai/zen - hiddenModels: [] # ids withheld from every listing sessionIdEnv: "OPENCODE_SESSION_ID" freeModelMarker: "free" usageBaseURL: "https://opencode.ai/zen/go/v1" @@ -398,7 +377,7 @@ These exist in the schema but render no control — each is a literal, a marker, originClient: "cli" injectProject: true # attach workspace folder (or 'global'); false omits the header injectCoreTools: true - enrichModels: true # merge catalog specs into model info + gateway /models listings + enrichModels: true # catalog into the gateway /models listing + Settings model discovery usageEnabled: true # off = no meter and no price row # File-level only debug options: debug: false # debugFile is written only while this is true @@ -409,6 +388,14 @@ These exist in the schema but render no control — each is a literal, a marker, --- +## Removing or disabling the plugin + +Disabling or removing the plugin hands both planes back to `llm-pi-ai`, with nothing to clean up first: the plane entries were never moved out of its `providers` block. + +- **`llm-pi-ai` serves the ids it knows again**, on its own adapter, endpoint family and reasoning rules. +- **Ids it cannot serve drop out of the composer.** That is an id its installed catalog does not describe — a catalog-unknown id, or a newer model than its pi carries. +- **The next Settings edit of that plane is refused until they are removed**, because `llm-pi-ai` validates the whole plane entry on every save. Delete those ids from the plane's model list, and the edit goes through. + ## 🛠 Troubleshooting | Symptom | Likely cause | Fix | @@ -417,7 +404,7 @@ These exist in the schema but render no control — each is a literal, a marker, | `400 MissingSessionID` | No session header attached | Ensure `dsh-opencode-patch` is listed in the profile's `dsh.profile.bundles` | | Auto Review fails with `TRANSPORT: Connection error` | Host process not restarted since the update | Stop and restart `dsh web` so the new `lib/index.mjs` loads | | Quota ring never appears for a Go model | **Restart `dsh web`, then read the browser console** — the plugin is loaded through a `link:`, so `lib/index.mjs` is read once at process start and a page reload alone keeps the old host code; the meter reports which of its paths returned nothing | The console names the cause: `no remote to mount on`, `did not mount` with the exception, `still waiting for remote.opencodeGoUsage`, `no opencodeGoUsage service on the remote` with the keys it does expose, or `the answer is not a usage shape` with what came back. A missing or unroutable credential is one cause of several, not the only one | -| A catalog model is missing from Settings → Fetch, or a retired one persists | Stale host module, enrichment off, or the adapter answered from its packaged catalog | `pnpm run build`, restart `dsh web`, keep **Enrich model metadata** on, fetch from the matching route, search the exact id (e.g. `space-bunny-free`) | +| A catalog model is missing from Settings → Fetch, or a retired one persists | Stale host module, enrichment off, or the adapter answered from its packaged catalog | `pnpm run build`, restart `dsh web`, keep **Enrich listings from the OpenCode catalog** on, fetch from the matching route, search the exact id (e.g. `space-bunny-free`) | | Popover shows “Limit reached” in red | 100% of a rolling/monthly window reached | Open the [OpenCode Console](https://opencode.ai/console) and enable _Use balance_ to overflow into Zen credits | | Non-OpenCode models misbehaving | Unrelated to this patch | Traffic to other providers passes through untouched | @@ -425,10 +412,17 @@ These exist in the schema but render no control — each is a literal, a marker, ## Changed in 1.x -- **A smaller public API.** The package's entry exports `apply`, `Config`, `inject` and `name` — the names the host reads — and the config types (`PluginConfig`, `ResolvedPluginConfig`, `ModelOverrideConfig`, `ModelRouteConfig`, `EndpointFamily`, `KeySourcePolicy`). Everything else it used to re-export is internal now. +- **A smaller public API.** The package's entry exports `apply`, `Config`, `inject` and `name` — the names the host reads — and the config types (`PluginConfig`, `ResolvedPluginConfig`, `EndpointFamily`, `KeySourcePolicy`). Everything else it used to re-export is internal now; `ModelOverrideConfig` and `ModelRouteConfig` went with the settings they typed. - **`./src/*` is no longer an export.** `src/` was never in the published files, so the subpath resolved in the repository and nowhere else. -- **`internalRoutes` was removed.** The per-protocol routes always mount. A profile row that still carries `internalRoutes` validates and the key is ignored. -- **Disabling or reloading the plugin restores the host methods it wrapped.** The model listing, model discovery and model-info decorations, and the plane adapters' fill, come off on a disable; a live reload replaces them instead of stacking another layer, so a setting removed in the reload stops applying. +- **The plugin serves `opencode` and `opencode-go` itself, and mounts no hidden route.** `opencode-responses`, `opencode-anthropic`, `opencode-google` and their `opencode-go-*` twins are gone: every model is served on its plane's own route, on the protocol its catalog row names. `internalRoutes` was removed first, and a row that still carries it validates and the key is ignored. `providers` now names the routes whose turns the plugin decorates, and always includes both planes. +- **A model whose reasoning is only on or off offers Off / On.** It used to offer the whole level ladder, every rung sending the same "on". A saved selection of Minimal, Xhigh or Max on such a model no longer resolves: pick On (or Off) again. Off is offered only where the model's protocol can turn reasoning off. +- **Chat Completions moved to the inference family by default.** Completions turns go to `https://opencode.ai/inference/openai/v1` on Zen and `https://opencode.ai/inference/go/openai/v1` on Go, where Responses, Messages and Google already went. `endpoint: legacy` keeps every protocol on `/zen/v1` (`/zen/go/v1` on Go), except Go's Google, which stays on `/inference/go/google/v1beta`. +- **A Go plane without `apiKeyEnv` authenticates with `OPENCODE_API_KEY`.** A plane's key now resolves by `llm-pi-ai`'s own rule (D20), and pi's own OpenCode lookup reads `OPENCODE_API_KEY` on both planes. Go's Responses, Messages and Google models used to default to `OPENCODE_GO_API_KEY` when the entry named nothing (its Chat Completions models already took pi's rule), so an `opencode-go` entry without `apiKeyEnv` now sends the Zen key on every protocol. The meter follows: it captures the key a Go turn carries and prefers it, so after the first Go turn it sends the Zen key to the Go usage endpoint, is refused, and falls back to the Zen pill or "subscription required". Add `apiKeyEnv: OPENCODE_GO_API_KEY` to the Go entry; a Go key stored only in DSH Credentials authenticates a Go turn only through that reference. +- **Any saved id is served.** A model the catalog does not describe is accepted when you save it, and sent as Chat Completions; it used to be refused at save time. +- **The keyless listing filter is gone.** A plane whose key does not resolve lists its declared models, and the turn names the missing credential, rather than hiding the priced models. +- **`modelOverrides`, `maxContextWindow` and `modelRoutes` were removed** (and the documented `hiddenModels`, which nothing ever read). A model's own row in the plane's entry carries its name, context window, output limit, input modalities and reasoning efforts, and the catalog fills the rest; a row's `contextWindow` is the stand-in for `maxContextWindow`. A row that still carries the removed keys validates, and the keys are ignored. +- **No per-model protocol pin.** `modelRoutes` could send one model on a protocol of your choosing; nothing replaces that. A model the catalog or pi describes always speaks the protocol they name; the plane entry's route-level `api` applies only to ids neither describes, and `baseURL` is route-level only. Cost is not a row field either: it comes from the catalog, then pi. +- **Disabling or reloading the plugin restores the host methods it wrapped.** Model discovery and the Settings graft come off on a disable, and a live reload replaces them instead of stacking another layer. The planes go back to `llm-pi-ai` — see [Removing or disabling the plugin](#removing-or-disabling-the-plugin). - **`@viztor/dsh-opencode` is no longer published.** Its last version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the final message; install `dsh-opencode-patch` or `@viztor/dsh-opencode-patch` instead. ## 📜 Compatibility & Verification @@ -448,11 +442,11 @@ One range covers the whole line on both channels: its lower bound (`alpha.1`) so | **Harness line** | **`0.2.1`**, `alpha` and `rc` channels alike (peer: `>=0.2.1-alpha.1 <0.2.2`) | | **Plugin package** | [`dsh-opencode-patch`](https://www.npmjs.com/package/dsh-opencode-patch) | | **Host profile** | DSH Web profile (`patchReload: live`) | -| **Routes claimed** | `opencode`, `opencode-go`, `opencode-responses`, `opencode-anthropic` | -| **Gateways** | `opencode.ai/zen/v1` (`/responses`, `/chat/completions`, `/messages`, `:streamGenerateContent`), `zen/go/v1` (`/chat/completions`) | +| **Routes claimed** | `opencode`, `opencode-go` — served by the plugin's own adapter; `llm-pi-ai` keeps every other route | +| **Gateways** | `opencode.ai/inference/{openai,anthropic,google}/…` and `inference/go/{openai,anthropic,google}/…` by default; `opencode.ai/zen/v1` and `zen/go/v1` with `endpoint: legacy` (Go's Google stays on `inference/go/google/v1beta`) | | **Catalog** | Generated from `models.opencode.ai/api.json`; retirement follows the registry's own `status: "deprecated"` flag, with no hand-maintained id list | -| **Upstream pi** | **Not bundled.** The bridge loads whatever `@earendil-works/pi-ai` the harness installed, so a harness bump never skews a pinned copy | -| **Verification gate** | `vp check` clean, **961** deterministic tests green (unit + offline gateway mock), full schema validation, consumer install + load ([`scripts/check.ts`](./scripts/check.ts)) | +| **Upstream pi** | **Not bundled.** The plane adapter loads whatever `@earendil-works/pi-ai` the harness installed, so a harness bump never skews a pinned copy | +| **Verification gate** | `vp check` clean, every deterministic test green (unit + offline gateway mock, in order and shuffled), full schema validation, consumer install + load ([`scripts/check.ts`](./scripts/check.ts)) | --- diff --git a/README.zh-CN.md b/README.zh-CN.md index b441d5e0..d623158b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -42,7 +42,7 @@ **这些能用之后剩下的**是另一个问题,而且与账号无关:OpenCode 的模型分布在**四种不同的 API 形状**上,而 DSH 是按 provider 行选择传输协议的——一条路由只能说一种形状。所以走 Responses / Messages / Mistral 的模型在基础问题解决之后就会返回 `500`。这正是插件**按模型而不是按 provider** 路由的原因。 -插件在网络层补齐所有缺失的协议要素——**且仅针对 OpenCode 路由**(`opencode` / `opencode-go` / `opencode-responses` / `opencode-anthropic` / `opencode-google`)。其余全部流量(DeepSeek、OpenAI、Anthropic、GitHub)原样通过。 +插件自己服务两条 OpenCode 路由——`opencode` 与 `opencode-go`,每个模型都走目录指定的协议——并在网络层补齐所有缺失的协议要素,**且仅针对这两条路由**。其余全部流量(DeepSeek、OpenAI、Anthropic、GitHub)原样通过。 **三条主线。** @@ -57,7 +57,7 @@ - 🧰 免费层 `read` + `bash` 工具 schema 回退,让免费层的请求体带上它的端点所要求的工具定义——这去掉的是 `403` 的一个成因,而不是全部:网关的那道闸不在请求里,实测如此 - 📇 基于 OpenCode 官方目录(`models.opencode.ai`,即 models.dev 发布的那份注册表)的模型目录,带离线预置与后台 SWR 刷新——名称、上下文窗口、价格 - ⭕ 实时双模式停靠栏计量——Go 额度环(5 小时 / 每周 / 每月)或 Zen 按量计费胶囊,外加会话消耗与模型费率 -- 🕵️ 严格凭据隔离——Zen 密钥(`oc_sk_…`)绝不查询 Go 额度端点 +- 🕵️ Go 计量凭据防护——Go 额度端点绝不会收到 `OPENCODE_API_KEY` 引用;它读取 Go 条目的 `apiKeyEnv`(否则 `OPENCODE_GO_API_KEY`),或某次 Go 平面请求携带的密钥 **目录** @@ -66,10 +66,12 @@ 3. [支持的模型与协议](#-支持的模型与多协议路由) 4. [界面](#-界面) 5. [配置参考](#-配置参考) -6. [故障排查](#-故障排查) -7. [兼容性与验证](#-兼容性与验证) -8. [深入解析](./docs/deep-dive.md) — 协议内部实现,已移出本 README -9. [署名与许可](#-署名与许可) +6. [移除或停用插件](#移除或停用插件) +7. [故障排查](#-故障排查) +8. [1.x 变更](#1x-变更) +9. [兼容性与验证](#-兼容性与验证) +10. [深入解析](./docs/deep-dive.md) — 协议内部实现,已移出本 README +11. [署名与许可](#-署名与许可) --- @@ -101,7 +103,7 @@ npm install dsh-opencode-patch } ``` -**3. 添加凭据**,让额度计量能解析到密钥——在 DSH Credentials 或你的环境变量中存储 `OPENCODE_GO_API_KEY`(Go 订阅,`sk-…`)和/或 `OPENCODE_API_KEY`(Zen 按量计费,`oc_sk_…`)。 +**3. 添加凭据**,让额度计量能解析到密钥——在 DSH Credentials 或你的环境变量中存储 `OPENCODE_GO_API_KEY`(Go 订阅,`sk-…`)和/或 `OPENCODE_API_KEY`(Zen 按量计费,`oc_sk_…`)。**在 Go 平面上要写明它:** 给 `opencode-go` 条目加上 `apiKeyEnv: OPENCODE_GO_API_KEY`。没有 `apiKeyEnv` 的平面条目按 pi 自己的查找认证,而它在两个平面上读的都是 `OPENCODE_API_KEY`,所以没有写明的 Go 条目发送的是 Zen 密钥。 **4. 重启 `dsh web`。** 宿主 bundle 只在启动时导入(`hmr root: []`),因此要重启才会加载 `lib/index.mjs`;客户端 UI 变更(`lib/client.js`)只需刷新浏览器。 @@ -119,7 +121,7 @@ npm install dsh-opencode-patch | 子代理丢失对话上下文 | **父会话跟踪**(`x-opencode-parent-session-id`、`x-parent-session-id`) | | Auto Review 调用报 `TRANSPORT: Connection error` | **回退会话轮次捕获**,在评估调用之间保留轮次状态 | | 额度与余额不可见 | **实时双模式停靠栏计量**,显示 Go 额度或 Zen 按量计费 | -| Zen 密钥在 Go 用量上触发 `403 EntitlementError` | **严格凭据隔离**,让 Zen 密钥不接触 Go 端点 | +| Zen 密钥在 Go 用量上触发 `403 EntitlementError` | **计量绝不为 Go 读取 `OPENCODE_API_KEY` 引用。** 它依次使用:Go 条目上的字面 `apiKey`、Go 平面请求捕获到的密钥、Go 条目的 `apiKeyEnv`(`OPENCODE_API_KEY` 引用按 `OPENCODE_GO_API_KEY` 读取)、任意捕获的 Go 平面密钥,最后是需显式开启的安装存储。Go 回合携带其条目所写明的密钥;没有写明的条目发送 pi 的 `OPENCODE_API_KEY`,请求补丁把它当作 Go 密钥记录,于是从第一次 Go 回合起计量就把它发往 Go 用量端点并得到这个 `403`。在 Go 条目上写明 `apiKeyEnv: OPENCODE_GO_API_KEY`,让回合与计量一致;或在 Settings 中把凭据来源设为“优先已声明密钥” | | 所有项目共用一个 `"global"` 遥测桶 | **动态工作区归属**,由当前 `session.header.cwd` 解析 | | 网关 `/models` 返回截断且无名称的列表 | **目录补全**(`models.opencode.ai`):显示名、上下文窗口与价格 | @@ -127,71 +129,51 @@ npm install dsh-opencode-patch ## 🧭 支持的模型与多协议路由 -**两种端点,两种凭据形状。** OpenCode 的目录在两个层级上定义端点:**provider 默认端点**——Zen 是 `https://opencode.ai/zen/v1`,Go 是 `https://opencode.ai/zen/go/v1`——以及**逐模型的推理端点**,目录里设置时会覆盖默认值(CLI 读 `model.api.url`,取不到就回落到 provider 默认)。插件**两个都不钉死**:它匹配的是 `opencode.ai/zen` 这个标记,所以走默认端点的模型和走自己端点的模型都会被补丁覆盖。 +**每个平面一条路由,所有协议都在这条路由上。** 插件用自己的适配器服务 `opencode`(Zen)和 `opencode-go`(Go)——它是宿主自带 `llm-pi-ai` 适配器的子类,通过宿主自己的 `registerAdapter` 注册在宿主的 `llm` 服务上。你声明的其他路由仍由 `llm-pi-ai` 服务。每个模型按其目录行指定的协议派发,所以没有第二条路由需要声明、挂载或隐藏。 -这些端点的认证有**两种**,因为它们说三种线格式:OpenAI 那两个平面发 `Authorization: Bearer `,Anthropic 平面发 `x-api-key: `。插件**两种都读**——先 `Authorization`,再 `x-api-key` / `api-key`——所以凭据无论由哪个平面带过来都能被捕获,Go 额度查询也仍然找得到密钥。 +网关提供四种线路家族,而 **OpenAI 自己就占了两种**:Chat Completions 与 Responses 是**两种不同的线路格式**,不是同一个东西的两个名字。需要 Responses 的模型在 completions 端点上不工作,而网关**不会回退**——它直接返回 `500`。四种都由 pi 自己的协议实现服务。 -网关提供四种线路家族,而 **OpenAI 自己就占了两种**:Chat Completions 与 Responses 是**两种不同的线路格式**,不是同一个东西的两个名字。需要 Responses 的模型在 completions 端点上不工作,而网关**不会回退**——它直接返回 `500`。 +**两个端点家族。** OpenCode 在两个分别授权的家族上应答。**modern**——默认值,也是 `endpoint: auto` 的含义——是目录指定的逐协议推理家族:Zen 为 `https://opencode.ai/inference/{openai,anthropic,google}/…`,Go 为 `https://opencode.ai/inference/go/{openai,anthropic,google}/…`。**legacy**(`endpoint: legacy`)是每个平面一个基址的旧家族:`https://opencode.ai/zen/v1` 与 `https://opencode.ai/zen/go/v1`。平面条目上的 `baseURL` 按其路径选择家族,其他主机按原样使用。 -DSH 能说其中四种,插件就把这四种都路由掉。**Google Generative AI 走插件自己挂的路由 `opencode-google`** ——它曾经是唯一没有路由的一种,现在不是了。见下表。 +这些端点的认证有**三种**,因为它们说三种线格式:OpenAI 平面发 `Authorization: Bearer `,Anthropic 平面发 `x-api-key: `,Google 平面发 `x-goog-api-key: `。 ### 1. OpenCode Zen (`provider: opencode`) — 按量计费与免费额度 -- **Anthropic Messages** (`https://opencode.ai/zen/v1/messages`):`claude-sonnet-4-5`、`claude-opus-4-7`、`claude-haiku-4-5`、`qwen3.8-flash` -- **OpenAI Responses** (`https://opencode.ai/zen/v1/responses`):`gpt-5.4`、`gpt-5.2`、`gpt-5.1-codex-max`、`muse-spark-1.3`、`space-bunny-free`,以及免费层的 `muse-spark-1.3-contributor-free` -- **OpenAI Chat Completions** (`https://opencode.ai/zen/v1/chat/completions`):`deepseek-v4.1-flash`、`kimi-k2.5`、`kimi-k3`、`minimax-m2.5`、`glm-5.2`,外加免费层的 `nemotron-3-ultra-free`、`ling-3.0-flash-fin-free`、`mimo-v2.6-flash-free` -- **Google Generative AI** (`https://opencode.ai/zen/v1/models/*:streamGenerateContent`):`gemini-3.8-flash`、`gemini-3.1-pro`、`gemini-3.5-flash-lite` +- **Anthropic Messages** (`https://opencode.ai/inference/anthropic/v1/messages`):`claude-sonnet-4-5`、`claude-opus-4-7`、`claude-haiku-4-5`、`qwen3.8-flash` +- **OpenAI Responses** (`https://opencode.ai/inference/openai/v1/responses`):`gpt-5.4`、`gpt-5.2`、`gpt-5.1-codex-max`、`muse-spark-1.3`、`space-bunny-free`,以及免费层的 `muse-spark-1.3-contributor-free` +- **OpenAI Chat Completions** (`https://opencode.ai/inference/openai/v1/chat/completions`):`deepseek-v4.1-flash`、`kimi-k2.5`、`kimi-k3`、`minimax-m2.5`、`glm-5.2`,外加免费层的 `nemotron-3-ultra-free`、`ling-3.0-flash-fin-free`、`mimo-v2.6-flash-free` +- **Google Generative AI** (`https://opencode.ai/inference/google/v1beta/models/*:streamGenerateContent`):`gemini-3.8-flash`、`gemini-3.1-pro`、`gemini-3.5-flash-lite` ### 2. OpenCode Go (`provider: opencode-go`) — 订阅额度 -- **Google Generative AI**(`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`):`gemini-4-argon`,由内部适配器 `opencode-go-google` 服务。在现有 `opencode-go` 提供商下配置该模型即可,适配器继承该提供商的 API Key 引用(默认 `OPENCODE_GO_API_KEY`),支持图片附件和带签名的工具调用回放。只挂载已配置的模型,可用范围仍由 Go 套餐决定。Go 没有旧版 Google 接口,因此 `endpoint: legacy` 也使用此地址。 - -- **OpenAI Chat Completions**(`https://opencode.ai/zen/go/v1/chat/completions`):`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `iceman-contributor-free`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` -- **OpenAI Responses**(`https://opencode.ai/zen/go/v1/responses`):`gpt-5.6-luna`, `gpt-6-luna`, `grok-4.5`, `grok-4.6`, `grok-4.7`, `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor` -- **Anthropic Messages**(`https://opencode.ai/zen/go/v1/messages`):`claude-haiku-5-5`, `minimax-m2.7`, `minimax-m3`, `qwen3.7-plus`, `qwen3.8-flash`, `qwen3.8-max` +- **OpenAI Chat Completions**(`https://opencode.ai/inference/go/openai/v1/chat/completions`):`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `iceman-contributor-free`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` +- **OpenAI Responses**(`https://opencode.ai/inference/go/openai/v1/responses`):`gpt-5.6-luna`, `gpt-6-luna`, `grok-4.5`, `grok-4.6`, `grok-4.7`, `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor` +- **Anthropic Messages**(`https://opencode.ai/inference/go/anthropic/v1/messages`):`claude-haiku-5-5`, `minimax-m2.7`, `minimax-m3`, `qwen3.7-plus`, `qwen3.8-flash`, `qwen3.8-max` +- **Google Generative AI**(`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`):`gemini-4-argon`,支持图片附件和带签名的工具调用回放。Go 没有旧版 Google 接口,因此两个家族都使用这一个地址。 - 由实时三窗口额度计量监控(5 小时滚动、每周、每月)。完整集合随内置目录发布——参见[权威模型目录](#7-权威模型目录双本地预置--实时-swr-更新)。 ### 3. 模型的协议是怎么定的 —— 以及为什么你什么都不用配 Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在该模型需要不同 SDK 时**才逐模型标注 `provider.npm` —— 所以这个字段的**存在本身就是信号**,它决定走哪条线路协议。没有任何手工匹配: -| models.dev `provider.npm` | 模型数 | 协议 | 服务自 | -| :-- | --: | :-- | :-- | -| _(缺失)_ | 24 | OpenAI Chat Completions | 你配置的路由 | -| `@ai-sdk/openai` | 31 | OpenAI Responses | `opencode-responses` | -| `@ai-sdk/anthropic` | 17 | Anthropic Messages | `opencode-anthropic` | -| `@ai-sdk/google` | 8 | Google Generative AI | `opencode-google` | -| `@ai-sdk/mistral` | 1 | _(本插件不服务的方言)_ | **不提供** | +| models.dev `provider.npm` | 模型数 | 协议 | +| :-- | --: | :-- | +| _(缺失)_ | 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` 的 81 个**在用**模型,统计于 2026-10-27 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 81 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 -插件把这两条内部路由同时挡在模型选择器和 _Settings → Models_ 之外。**三条性质今天就成立,还有一条尚未成立:** - -- **你的挑选仍然有效。** 选择器显示的就是你列出的模型,每个模型都会**自动匹配到正确的协议**。OpenCode 的模型分布在三种形状上——OpenAI Chat Completions(默认)、OpenAI Responses、Anthropic Messages——需要后两种的模型会被派发到对应路由。不需要手工声明第二条路由。 -- **永远不会提供跑不通的模型。** 那 8 个 `@ai-sdk/google` 模型由插件自己的适配器服务(路由 `opencode-google`),所以它们**不再被剔除**——插件曾经没有对应协议,现在有了。真正会被剔除的是 `@ai-sdk/mistral` 的那一个:它的方言网关怎么回答还没有测过,所以宁可不提供,也不送它去一个凭空想出来的地方。 -- **一份凭据。** 所有路由模型共用你已配置的 `opencode` key,经凭据服务解析,**不会重新问你要**。 - -**如果你更想自己声明路由**:`opencode-responses`(以及你用上 Anthropic 平面模型后的 `opencode-anthropic`)可以写进 profile 的 `llm-pi-ai` `providers` 块里,并列出它服务的模型。声明是一个选择,不是要求: - -```yaml -opencode-responses: - api: openai-responses - baseURL: https://opencode.ai/zen/v1 - headers: - authorization: Bearer unused # 由 fetch 补丁换成你的 key - models: - - id: muse-spark-1.3-contributor-free - name: Muse Spark 1.3 Free - contextWindow: 1048576 - maxTokens: 131072 - input: [text, image] -``` - -**这条路由由插件替你注册**,所以上面那段配置是可选的。`responses-provider.ts` 会在进程内挂载宿主自己的 `llm-pi-ai`:隔离 authorization 与 settings 两个接缝,并在一层只转发 adapter 注册的 `llm` 门面之后挂载——因此它既不会重复登记整个 catalog 目录、settings 命名空间,也不会多注册任何一条 sign-in flow。这一版是对着真实 harness 验证的,不是桩:路由注册成功,宿主自己的 41 条 catalog 与 41 条 sign-in flow 原封不动,卸载时该路由被撤回。 +由此有四条性质: -这些路由继承**你的**凭据:每一条都沿用你 `opencode` 路由已经声明的同一个 `apiKeyEnv`,所以自定义的 key 引用不需要你再说一遍。你在 profile 里已经声明过的路由会被尊重——插件不会覆盖你的模型列表。 +- **你的挑选仍然有效。** 每个平面服务的正是其 `llm-pi-ai` 条目列出的模型;条目没有列出任何模型时,服务整个目录。你保存的任何 id 都会被接受并服务:目录与 pi 都未描述的 id 按 Chat Completions 发送,除非平面条目指定了路由级的 `api`——它只作用于这类 id。 +- **你的模型行优先。** 模型的名称、上下文窗口、输出上限与输入模态,先取你自己的模型行,再取目录,最后取 pi 的内置数据。推理档位先取你行里的 `reasoningEfforts`,再取目录。价格与协议不是模型行的字段:价格先取目录、再取 pi,协议先取目录的 SDK、再取 pi 的条目。 +- **只有开/关两种推理的模型提供“关 / 开”。** 只有该模型的协议确实能关闭推理时才提供“关”;有真正档位列表的模型只提供它自己的档位;档位列表中含 `none` 的 Chat Completions 模型同样提供“关”,按 pi 自己条目的写法发送 `reasoning_effort: "none"`。 +- **一份凭据,按宿主自己的规则。** 平面的认证方式与 `llm-pi-ai` 相同:条目的 `apiKeyEnv` 经凭据服务解析(未挂载凭据服务时读启动环境),保存在 `llm-pi-ai/opencode` 下的登录记录,否则用 pi 自己的 `OPENCODE_API_KEY` 查找——两个平面读的是同一个变量:请在 Go 条目上写明 `apiKeyEnv: OPENCODE_GO_API_KEY`。只有在没有声明引用、且部署选择开启时,才会读取 OpenCode 安装自己的存储。密钥解析不到的平面仍列出其模型,回合在发出任何请求前以 `MISSING_CREDENTIAL` 失败。 -若宿主没有已加载的 `llm-pi-ai` 条目,插件不会注册任何东西,只会在日志里说明;此时请在 profile 里显式声明该路由。 +**设置仍归宿主所有。** 平面条目一如既往留在 `llm-pi-ai` 的 `providers` 块里,_Settings → Models_ 也在那里读写它们。插件把去掉这两个平面的配置副本交给 `llm-pi-ai`,让它不服务这两个平面;并用 `llm-pi-ai` 自己的检查校验对平面的设置编辑,所以格式错误的条目会在写入前被拒绝。 ### 4. 覆盖的执行模式 @@ -244,7 +226,7 @@ opencode-responses: | **网关请求** | 恢复 User-Agent(默认开启) | `on` | 恢复官方 OpenCode CLI 的 `User-Agent`,使 Cloudflare WAF 校验通过 | | **网关请求** | 注入客户端来源头(默认开启) | `on` | 在网关流量上注入 `x-opencode-client`(以及来源请求头集合) | | **网关请求** | 附带工作区项目标识(默认开启) | `on` | 用当前文件夹名标记 `x-opencode-project`;关闭则省略该请求头 | -| **模型与免费额度** | 用 OpenCode 官方目录补全模型信息(默认开启) | `on` | 把目录的上下文上限、模态与思考档位补进模型信息,并让网关 `/models` 列表带上完整目录。**不会**往选择器里添加模型——选择器只显示已声明的;要添加请用卡片的**检查目录** | +| **模型与免费额度** | 用 OpenCode 官方目录补全列表(默认开启) | `on` | 让网关 `/models` 列表带上完整目录,并把目录提供给设置页的模型发现。**不会**往选择器里添加模型——选择器只显示已声明的;要添加请用卡片的**检查目录**。已声明模型自身的信息(上下文、模态、思考档位)始终来自你的模型行与目录,与此开关无关 | | **模型与免费额度** | 自动补全核心工具(默认开启) | `on` | 补上免费层 `/responses` 请求体所需的 `read` + `bash` schema | | **配额计量** | 开启输入区用量计量(默认开启) | `on` | 在输入框停靠栏挂载实时计量表——Go 额度环,或 Zen 会话消耗 | | **配额计量** | 显示会话消耗与模型费率(默认开启) | `on` | 追加显示本会话累计花费与当前模型每百万 Token 费率 | @@ -258,7 +240,7 @@ opencode-responses: **发往哪个端点家族。** 网关在两套端点上都应答——较早的 `/zen/v1`(Go 为 `/zen/go/v1`),所有协议共用一个基址;以及较新的按协议划分的 `inference` 家族(`/inference/openai/v1`、`/inference/anthropic/v1`、`/inference/google/v1beta`,Go 为 `/inference/go/…`)——而两者的授权是**彼此独立**的:在一边可用的账户不保证在另一边可用。`modern` 将每个协议发往各自的 inference 路径;`auto`(默认值)是 `modern` 的别名;`legacy` 固定使用较早的一套,适用于账户只在那里可用的情况。 -**以哪个凭据为准。** `keySource` 决定插件可见的多个凭据源的优先级——`auto` 优先使用你声明过的密钥,其次是从实时请求中捕获的密钥,最后是声明的凭据引用;`request` 把捕获到的密钥提到最前;`configured` 则相反。其下的 `readOpencodeStore` 允许插件读取 OpenCode 自己保存的凭据,且**仅作最后手段**。两者默认分别为 `auto` 与关闭,因此在你说要改之前一切照旧。 +**以哪个凭据为准。** 平面上的回合按宿主自己的规则认证(见[模型的协议是怎么定的](#3-模型的协议是怎么定的--以及为什么你什么都不用配))。对额度计量与请求补丁而言,`keySource` 决定插件可见的多个凭据源的优先级——`auto` 优先使用你声明过的密钥,其次是从实时请求中捕获的密钥,最后是声明的凭据引用;`request` 把捕获到的密钥提到最前;`configured` 则相反。其下的 `readOpencodeStore` 允许插件读取 OpenCode 自己保存的凭据,且**仅作最后手段**。两者默认分别为 `auto` 与关闭,因此在你说要改之前一切照旧。 覆盖类配置项(字面字符串、标记、路由列表)刻意只留在配置里,让默认值适用于所有有文档记载的配置——参见[配置参考](#-配置参考)。 @@ -355,7 +337,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 | 配置项 | 默认值 | 保留在配置中的原因 | | :-- | :-- | :-- | -| `providers` | `opencode`, `opencode-go`, `opencode-responses`, `opencode-anthropic` | 要拦截的路由 id;必须覆盖本层声明的每一条路由 | +| `providers` | `opencode`, `opencode-go` | 插件为其回合补齐请求的路由 id;两个平面总是包含在内,只有中继路由需要添加 | | `gatewayUrls` | `opencode.ai/zen` | 标记网关流量的 URL 子串;只有镜像或中继才会改它们 | | `userAgent` | 空(= 规范 CLI UA) | 字面覆盖;默认值就是网关所期望的值 | | `originClient` | `cli` | `x-opencode-client` 的字面值 | @@ -370,11 +352,9 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 - id: dsh-opencode-patch name: "dsh-opencode-patch" config: - providers: + providers: # 两个平面总是包含在内 - opencode - opencode-go - - opencode-responses - - opencode-anthropic gatewayUrls: - opencode.ai/zen sessionIdEnv: "OPENCODE_SESSION_ID" @@ -387,7 +367,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 originClient: "cli" injectProject: true # attach workspace folder (or 'global'); false omits the header injectCoreTools: true - enrichModels: true # merge catalog specs into model info + gateway /models listings + enrichModels: true # catalog into the gateway /models listing + Settings model discovery usageEnabled: true # off = no meter and no price row # File-level only debug options: debug: false # 仅在 debug 开启时写入 debugFile @@ -398,6 +378,14 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 --- +## 移除或停用插件 + +停用或移除插件会把两个平面交还给 `llm-pi-ai`,事先无需清理:平面条目从未离开它的 `providers` 块。 + +- **`llm-pi-ai` 重新服务它认识的 id**,使用它自己的适配器、端点家族与推理规则。 +- **它服务不了的 id 会从输入框的模型列表中消失。** 也就是它内置目录未描述的 id——目录未知的 id,或比它的 pi 更新的模型。 +- **在删除这些 id 之前,对该平面的下一次设置编辑会被拒绝**,因为 `llm-pi-ai` 每次保存都会校验整个平面条目。把这些 id 从平面的模型列表中删掉,编辑即可通过。 + ## 🛠 故障排查 | 症状 | 可能原因 | 解决方法 | @@ -405,8 +393,8 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 | 免费模型报 `403 FreeTierError` | 网关请求头被剥离,或缺少工具定义 | 保持**恢复 User-Agent**、**注入客户端来源头**与**自动补全核心工具**开启 | | `400 MissingSessionID` | 未附带会话请求头 | 确认 `dsh-opencode-patch` 已列入 profile 的 `dsh.profile.bundles` | | Auto Review 报 `TRANSPORT: Connection error` | 更新后宿主进程未重启 | 停止并重启 `dsh web`,让新的 `lib/index.mjs` 加载 | -| Go 模型始终不显示额度环 | 解析不到 Go 凭据,或当前激活的网关不是 `opencode-go` | 在 DSH Credentials 中存储 `OPENCODE_GO_API_KEY`,并经 `opencode-go` 路由 | -| 目录中的模型在 Settings → Fetch 里缺失,或已下线的模型仍然存在 | 宿主模块过期、补全关闭,或适配器用自带目录作答 | `pnpm run build`、重启 `dsh web`、保持**用 OpenCode 官方目录补全模型信息**开启、从匹配的路由获取、搜索精确 id(如 `space-bunny-free`) | +| Go 模型始终不显示额度环 | **重启 `dsh web`,然后查看浏览器控制台**——插件通过 `link:` 加载,`lib/index.mjs` 只在进程启动时读取一次,仅刷新页面仍运行旧的宿主代码;计量会报告它的哪条路径没有返回 | 控制台会指出原因:`no remote to mount on`、带异常的 `did not mount`、`still waiting for remote.opencodeGoUsage`、列出其实际暴露键的 `no opencodeGoUsage service on the remote`,或带返回内容的 `the answer is not a usage shape`。缺失或无法路由的凭据只是几个原因之一:在 `opencode-go` 条目上写明 `apiKeyEnv: OPENCODE_GO_API_KEY` 并在 DSH Credentials 中存储该密钥 | +| 目录中的模型在 Settings → Fetch 里缺失,或已下线的模型仍然存在 | 宿主模块过期、补全关闭,或适配器用自带目录作答 | `pnpm run build`、重启 `dsh web`、保持**用 OpenCode 官方目录补全列表**开启、从匹配的路由获取、搜索精确 id(如 `space-bunny-free`) | | 弹出面板以红色显示“已达限额” | 滚动 / 每月窗口已用到 100% | 打开 [OpenCode 控制台](https://opencode.ai/console)并启用 _使用余额(Use balance)_,以溢出到 Zen 额度 | | 非 OpenCode 模型行为异常 | 与本补丁无关 | 发往其他网关的流量原样通过 | @@ -414,9 +402,17 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 ## 1.x 变更 -- **更小的公开 API。** 包入口只导出 `apply`、`Config`、`inject` 和 `name`(宿主读取的名称),以及配置类型(`PluginConfig`、`ResolvedPluginConfig`、`ModelOverrideConfig`、`ModelRouteConfig`、`EndpointFamily`、`KeySourcePolicy`)。此前额外重新导出的其余内容现在都是内部实现。 +- **更小的公开 API。** 包入口只导出 `apply`、`Config`、`inject` 和 `name`(宿主读取的名称),以及配置类型(`PluginConfig`、`ResolvedPluginConfig`、`EndpointFamily`、`KeySourcePolicy`)。此前额外重新导出的其余内容现在都是内部实现;`ModelOverrideConfig` 与 `ModelRouteConfig` 随其所描述的设置一并移除。 - **`./src/*` 不再是导出路径。** `src/` 从未包含在发布文件中,所以这个子路径只在仓库内可解析,安装后并不存在。 -- **`internalRoutes` 已移除。** 按协议划分的内部路由总是会挂载。仍带有 `internalRoutes` 的 profile 配置行可以通过校验,该键会被忽略。 +- **插件自己服务 `opencode` 与 `opencode-go`,不再挂载任何隐藏路由。** `opencode-responses`、`opencode-anthropic`、`opencode-google` 及其 `opencode-go-*` 对应路由都已移除:每个模型都在其平面自己的路由上、按目录行指定的协议服务。`internalRoutes` 更早已移除,仍带有它的配置行可以通过校验,该键会被忽略。`providers` 现在指插件为其回合补齐请求的路由,并且总是包含两个平面。 +- **只有开/关两种推理的模型提供“关 / 开”。** 过去它提供整套档位,每一档发送的都是同一个“开”。此类模型上保存的 Minimal、Xhigh 或 Max 选择不再能解析:请重新选择“开”(或“关”)。只有模型的协议能关闭推理时才提供“关”。 +- **Chat Completions 默认改走推理家族。** Completions 回合在 Zen 上发往 `https://opencode.ai/inference/openai/v1`,在 Go 上发往 `https://opencode.ai/inference/go/openai/v1`,与 Responses、Messages、Google 一致。`endpoint: legacy` 让所有协议留在 `/zen/v1`(Go 为 `/zen/go/v1`),但 Go 的 Google 仍在 `/inference/go/google/v1beta`。 +- **没有 `apiKeyEnv` 的 Go 平面用 `OPENCODE_API_KEY` 认证。** 平面的密钥现在按 `llm-pi-ai` 自己的规则解析(D20),而 pi 自己的 OpenCode 查找在两个平面上读的都是 `OPENCODE_API_KEY`。过去条目未写明任何引用时,Go 的 Responses、Messages 与 Google 模型默认使用 `OPENCODE_GO_API_KEY`(其 Chat Completions 模型早已遵循 pi 的规则),所以没有 `apiKeyEnv` 的 `opencode-go` 条目现在在每种协议上都发送 Zen 密钥。计量随之变化:它捕获 Go 回合携带的密钥并优先使用,因此在第一次 Go 回合之后,它把 Zen 密钥发往 Go 用量端点、被拒绝,并退回到 Zen 胶囊或“需要订阅”。请给 Go 条目加上 `apiKeyEnv: OPENCODE_GO_API_KEY`;只存储在 DSH Credentials 中的 Go 密钥只有通过这个引用才能为 Go 回合认证。 +- **保存的任何 id 都会被服务。** 目录未描述的模型在保存时被接受,并按 Chat Completions 发送;过去它会在保存时被拒绝。 +- **无密钥时的列表过滤已移除。** 密钥解析不到的平面会列出其声明的模型,由回合指出缺失的凭据,而不是隐藏付费模型。 +- **`modelOverrides`、`maxContextWindow` 与 `modelRoutes` 已移除**(以及文档中写过、却从未被读取的 `hiddenModels`)。平面条目中模型自己的行可以写名称、上下文窗口、输出上限、输入模态与推理档位,其余由目录补齐;行里的 `contextWindow` 代替 `maxContextWindow`。仍带有这些已移除键的配置行可以通过校验,这些键会被忽略。 +- **不再能按模型固定协议。** `modelRoutes` 可以让某个模型走你指定的协议;这一点没有替代。目录或 pi 描述过的模型总是使用它们指定的协议;平面条目的路由级 `api` 只作用于两者都未描述的 id,`baseURL` 也只在路由级。价格同样不是模型行的字段:先取目录,再取 pi。 +- **停用或重载插件会恢复它包装过的宿主方法。** 停用时模型发现与设置嫁接会被撤下,热重载会替换它们而不是再叠一层。两个平面交还给 `llm-pi-ai`——见[移除或停用插件](#移除或停用插件)。 - **`@viztor/dsh-opencode` 不再发布。** 其最后一个版本上的 "Renamed to @viztor/dsh-opencode-patch" 弃用提示即为最终信息;请改装 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。 ## 📜 兼容性与验证 @@ -436,11 +432,11 @@ Harness 按 `alpha` 与 `rc` 两个**通道**发布,它们是平行的发布 | **Harness 版本线** | **`0.2.1`**,`alpha` 与 `rc` 通道均可(peer:`>=0.2.1-alpha.1 <0.2.2`) | | **插件包** | [`dsh-opencode-patch`](https://www.npmjs.com/package/dsh-opencode-patch) | | **宿主 profile** | DSH Web profile(`patchReload: live`) | -| **声明的路由** | `opencode`、`opencode-go`、`opencode-responses`、`opencode-anthropic` | -| **网关** | `opencode.ai/zen/v1`(`/responses`、`/chat/completions`、`/messages`、`:streamGenerateContent`)、`zen/go/v1`(`/chat/completions`) | +| **声明的路由** | `opencode`、`opencode-go`——由插件自己的适配器服务;其他路由仍归 `llm-pi-ai` | +| **网关** | 默认为 `opencode.ai/inference/{openai,anthropic,google}/…` 与 `inference/go/{openai,anthropic,google}/…`;`endpoint: legacy` 时为 `opencode.ai/zen/v1` 与 `zen/go/v1`(Go 的 Google 仍在 `inference/go/google/v1beta`) | | **模型目录** | 由 `models.opencode.ai/api.json` 生成;下线判定直接跟随目录自身的 `status: "deprecated"` 标记,不维护任何手工 id 列表 | -| **上游 pi** | **不随包携带。** 桥接在运行时加载 harness 已安装的那份 `@earendil-works/pi-ai`,因此 harness 升级不会与本插件锁定的副本产生版本错位 | -| **验证关卡** | `vp check` 干净、**961** 个确定性测试全绿、完整 schema 校验、消费者安装 + 加载([`scripts/check.ts`](./scripts/check.ts)) | +| **上游 pi** | **不随包携带。** 平面适配器在运行时加载 harness 已安装的那份 `@earendil-works/pi-ai`,因此 harness 升级不会与本插件锁定的副本产生版本错位 | +| **验证关卡** | `vp check` 干净、全部确定性测试全绿(单元 + 离线网关模拟,顺序与乱序)、完整 schema 校验、消费者安装 + 加载([`scripts/check.ts`](./scripts/check.ts)) | --- diff --git a/cordis.patch.yml b/cordis.patch.yml index 027aa346..035e84b9 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -30,10 +30,9 @@ # Config-only overrides — the card deliberately hides these, because each is a # literal, a marker, or a reference whose default fits every documented setup. # See src/settings-fields.ts CONFIG_ONLY_FIELDS. -# providers: [string] provider route keys to intercept (default: opencode, opencode-go, -# opencode-responses, opencode-anthropic, opencode-google, -# opencode-go-responses, opencode-go-anthropic, opencode-go-google — covers every -# route this layer declares, including the Google bridge's). +# providers: [string] route ids whose turns this plugin decorates (default: opencode, +# opencode-go; both planes are always included, so only a relay route +# needs adding). # userAgent: string optional custom User-Agent override (default empty = use OpenCode CLI UA). # originClient: string x-opencode-client value (default 'cli'). # sessionIdEnv: string env var carrying a caller-supplied session id (default 'OPENCODE_SESSION_ID'). @@ -44,9 +43,6 @@ # families: ['opencode.ai/zen', 'opencode.ai/inference']). # usageBaseURL: string OpenCode Go gateway base URL (default 'https://opencode.ai/zen/go/v1'). # credentialOrg: string org id (wrk_...) sent with an account credential that came from elsewhere. -# maxContextWindow: number optional ceiling clamping model context windows (e.g. 131072). -# modelOverrides: object per-model overrides for contextWindow, maxTokens and metadata (default {}). -# modelRoutes: object per-model wire routing: { : { api, baseURL } } (default {}). # debug: true|false log every streamed call that receives the header. # debugFile: path optional append target for stream debug JSONL; written only while debug is on. - insert: @@ -56,12 +52,6 @@ providers: - opencode - opencode-go - - opencode-responses - - opencode-anthropic - - opencode-google - - opencode-go-responses - - opencode-go-anthropic - - opencode-go-google injectUserAgent: true userAgent: "" injectOriginHeaders: true diff --git a/docs/credentials.md b/docs/credentials.md index 183d64aa..03bbe535 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -8,13 +8,20 @@ The plugin's default is the deployment's own credential: whatever `apiKeyEnv` th ## Current request wiring -The primary fetch fallback keeps real request auth untouched, then resolves a missing or placeholder credential in this order: +**The plane adapter resolves a plane's key by `llm-pi-ai`'s own rule (D20)**, in `planeApiKeyResolver` (`src/host/plane-provider.ts`), before any request is built: - captured → declared environment key → installation key (opt-in) → nothing + a named apiKeyEnv → the credentials service (the launch environment only when no service is mounted) + no reference → pi's own lookup (a Sign-in record under llm-pi-ai/, then OPENCODE_API_KEY) → the installation's store (opt-in) + +**pi's own lookup reads `OPENCODE_API_KEY` on both planes** (pi 1.1.0's `providers/opencode.js` and `providers/opencode-go.js` both name that one variable). So a Go entry that names no reference authenticates every Go turn with the Zen key, and a Go key stored in DSH Credentials reaches a Go turn only as the entry's `apiKeyEnv: OPENCODE_GO_API_KEY`. Before P-R1 the Go plane's Responses, Messages and Google turns defaulted to `OPENCODE_GO_API_KEY`; that default went with the hidden routes, and the README's "Changed in 1.x" names it. The meter inherits the consequence: the fetch interceptor files the bearer of a Go-plane request as a Go key, and the meter's `auto` order puts that captured key ahead of the configured `OPENCODE_GO_API_KEY`, so after the first Go turn of a no-reference Go entry the meter sends the Zen key to the Go usage endpoint, which refuses it. `test/host-planes.test.ts` pins both turn cases on the Go plane, and the meter's switch from the Go key to the captured Zen key. + +A named reference that resolves nothing is `MISSING_CREDENTIAL`, and no request is sent; a credentials service that fails propagates its own error rather than reading as a missing key. A captured or literal key never answers for the adapter. The installation's store is read only when the deployment sets `readOpencodeStore`, only for a plane that names no reference, and only when pi's own lookup would find nothing: the resolver's answer is handed to pi as the request's explicit key, which pi lets win over its own lookup, so a store key returned any earlier would outrank a Sign-in record or an exported `OPENCODE_API_KEY`. -That third step reads the OpenCode installation's own store, and only when the deployment sets `readOpencodeStore`. The Google bridge resolves the route's own reference first and falls back the same way. Two sources that are no longer in this branch — the DSH grant and the login session — are `v2` work. +**The fetch interceptor's fallback is the second, narrower path.** It keeps real request auth untouched and fills only a missing or placeholder credential on gateway traffic, in this order: -The `configured` key-source policy still skips captured fallback; an API key already present in a request is never displaced. Empty and placeholder environment values do not block the login fallback. The Google bridge still raises for an unresolved named reference rather than replacing it with a login. + captured → declared environment key → installation key (opt-in) → nothing + +The `configured` key-source policy skips the captured fallback; an API key already present in a request is never displaced. Two sources that are no longer in this branch — the DSH grant and the login session — are `v2` work. Authentication follows the wire format: Bearer for OpenAI, `x-api-key` for Anthropic `/messages`, and `x-goog-api-key` for Google generation. Header extraction skips placeholders when another header carries a real key; placeholder detection is case-insensitive. The URL's pathname determines the plane, not query text or a conflicting turn provider. @@ -32,14 +39,14 @@ These contracts are exercised with fixture grants and SQLite databases through ` One reference resolves per route: -1. **The route's `apiKeyEnv`** — the reference the deployment declared, resolved through the credentials service with the launch environment as fallback. This is the zero-configuration default, and the whole behavior for a deployment that declares nothing else. +1. **The route's `apiKeyEnv`** — the reference the deployment declared, resolved through the credentials service when one is mounted and through the launch environment only when none is (a service miss does not fall through to the environment, as in `llm-pi-ai`). This is the zero-configuration default, and the whole behavior for a deployment that declares nothing else. 2. **A named reference that misses fails loud.** Never hand pi an `undefined` for a named ref: it would pick up an unrelated ambient key (`OPENAI_API_KEY` and friends) and bill another tenant. Where a route names no reference at all, the chain continues into the login and the installation's store — "The handoff" below is where that order lives. ## The planes are separate, so their credentials are -Zen credentials serve Zen calls, Go credentials serve Go calls, and never the other way: the planes are different accounts with different keys, different endpoints and different catalogs. +Zen credentials serve Zen calls, Go credentials serve Go calls: the planes are different accounts with different keys, different endpoints and different catalogs. On a plane turn that holds when each entry names its own reference; an entry that names none takes pi's `OPENCODE_API_KEY` on either plane (above), which is why the Go entry should name `OPENCODE_GO_API_KEY`. A route's credential is an **API key** — a reference in the credential store, or the environment — and that is a measured constraint rather than a first choice: the section below shows a login token is refused by the model endpoints the same way a wrong key is. @@ -72,7 +79,7 @@ v1 (`console login`) additionally reads `/api/user` and `/api/orgs`, stores the ## What the plugin never touches -- **The `google` route name.** A real deployment may configure Google AI Studio under that id; the plugin's internal route is named `opencode-google` precisely so that name stays free. +- **Any route but its two planes.** Since 1.x the plugin mounts no route of its own: a Google model on either plane is served on `opencode` or `opencode-go`, so a real deployment's `google` route (Google AI Studio, say) is never touched. - **The CLI's `auth.json`.** Reading it couples the plugin to another program's private file format; writing it collides with the program that owns it. The database that replaced it is read only when `readOpencodeStore` is on, and is never written. ## What the seam enforces about writes (verified, not assumed) @@ -141,6 +148,8 @@ Two more details worth copying. Secrets are `Redacted`, so they are never string So the shape to copy is two-layered, and both layers already exist here in some form: a source list (literal, config, installation, captured request) and an order that puts everything the user declared ahead of everything inferred. Reading a file is one entry in the first list, and the switch that turns it on belongs to the user, not to this plugin's default. +> **(`v2` record, from before 1.x.)** From here to the end of this section, the text describes the Google bridge (`google-bridge.ts`) and `responses-provider.ts`, both deleted in 1.x, and the tests that went with them. Plane credentials are resolved by `planeApiKeyResolver` in `src/host/plane-provider.ts` (D20, above); a login token would join that resolver's no-reference branch, where the opt-in store already sits. The measurements and the two obstacles below are kept as history. + **The two resolution paths, and which one is a gap.** `resolveGatewayKey` is deliberately two-faced, and the difference is tested: - **A named reference that resolves nothing RAISES.** The message carries the reference and the route, and `MISSING_CREDENTIAL` is in it. The reason is in the test: `undefined` would tell the underlying adapter to "resolve ambient auth instead", and it would bill whatever key it found first. @@ -249,7 +258,7 @@ What a login is FOR is measured, not assumed: the account's session token, on `/ Built, and the opt-in reader it prepared for: -- **The installation's own store, read only when asked.** `readOpencodeStore` is a config field, default `false`, with a card control beside the credential-source select. When it is on, `resolveOpencodeStoreKey` reads the credential OpenCode itself holds for the plane in hand and the step sits LAST in all three key-source policies — after a literal, after a captured request, after the named reference and the environment — so a credential declared in DSH always wins. Both paths that resolve a request's key consult it: the fetch interceptor's Authorization fallback, and the Google bridge's per-request resolver. Nothing logs a value from it, and `node:sqlite` keeps the module on the host side of the bundle rule. +- **The installation's own store, read only when asked.** `readOpencodeStore` is a config field, default `false`, with a card control beside the credential-source select. When it is on, `resolveOpencodeStoreKey` reads the credential OpenCode itself holds for the plane in hand and the step sits LAST in all three key-source policies — after a literal, after a captured request, after the named reference and the environment — so a credential declared in DSH always wins. Both paths that resolve a request's key consult it: the fetch interceptor's Authorization fallback, and the plane adapter's resolver (`planeApiKeyResolver`) for a plane that names no reference. Nothing logs a value from it, and `node:sqlite` keeps the module on the host side of the bundle rule. ## Reading the installation's own credential store needs no dependency diff --git a/docs/deep-dive.md b/docs/deep-dive.md index 5d629412..33a35d3e 100644 --- a/docs/deep-dive.md +++ b/docs/deep-dive.md @@ -121,13 +121,13 @@ Two endpoint levels, and two ways to authenticate. The endpoints above are not p Authentication follows the **wire shape**, not the endpoint: the OpenAI planes (`/chat/completions`, `/responses`) send `Authorization: Bearer `; the Anthropic plane (`/messages`) sends `x-api-key: `. `extractApiKeyFromHeaders` reads `Authorization` first and falls back to `x-api-key` / `api-key`, which is what makes one credential usable no matter which plane the adapter routed the turn through. -Credential isolation (preventing `403 EntitlementError`). The Go quota endpoint (`https://opencode.ai/zen/go/v1/usage`) is what the subscription windows come from, and the plugin queries it only with the credential the composition already uses for the Go route. **A key prefix does not decide this.** An earlier guard refused `oc_sk_…` keys there on the belief that the prefix meant "Zen, no Go entitlement"; the same prefix answers `200` with real windows. What separates the tiers is the observed request, never the string: see [engineering-notes.md](./engineering-notes.md), "A credential prefix is not an entitlement". +Which key the Go meter sends (and when it earns a `403 EntitlementError`). The Go quota endpoint (`https://opencode.ai/zen/go/v1/usage`) is what the subscription windows come from. Under the default `keySource` (`auto`) `resolveGoApiKey` tries, in order: a literal `apiKey` on the Go entry, a key captured from a Go-plane request, the Go entry's `apiKeyEnv` (an `OPENCODE_API_KEY` reference is read as `OPENCODE_GO_API_KEY`, and with no reference that is the default), any captured Go-plane key, then the installation's store when the deployment opted in. The captured step is the turn's own key, so the meter agrees with the turn only when the Go entry names its reference: a Go entry without `apiKeyEnv` authenticates its turns with pi's `OPENCODE_API_KEY` (D20), the request patch files that bearer as a Go key, and from the first Go turn the meter sends the Zen key here. `keySource: configured` ("Declared key first") puts the reference ahead of the captured key; naming `apiKeyEnv: OPENCODE_GO_API_KEY` on the Go entry fixes the turn and the meter together. **A key prefix does not decide this.** An earlier guard refused `oc_sk_…` keys there on the belief that the prefix meant "Zen, no Go entitlement"; the same prefix answers `200` with real windows. What separates the tiers is the observed request, never the string: see [engineering-notes.md](./engineering-notes.md), "A credential prefix is not an entitlement". ```json 403 {"type":"error","error":{"type":"EntitlementError","message":"OpenCode Go subscription required."}} ``` -The plugin isolates them: `resolveGoApiKey` excludes Zen keys from the Go usage query; if only a Zen key exists, usage discovery reports `configured: false` and the ring stays hidden instead of spamming 403s; an `EntitlementError` response is mapped to `configured: false` or to the Zen credit status. +Nothing in `resolveGoApiKey` tests whether a key is a Zen key, because nothing in the key says so; what keeps a Zen key off this endpoint is the reference the Go entry names. When one arrives anyway, the `EntitlementError` response is mapped to the Zen credit status when a Zen key resolves, and otherwise to `configured: false` ("OpenCode Go subscription required"), rather than retried. Go plan overflow to Zen credits. At 100% of the monthly quota, requests only fall back to Zen balance if _Use balance_ is enabled in the [OpenCode Console](https://opencode.ai/console). OpenCode exposes **no public balance API** (open feature request [anomalyco/opencode#10448](https://github.com/anomalyco/opencode/issues/10448)); overflow is handled server-side: @@ -150,10 +150,10 @@ How does this compare to Duskriver's [`dsh-opencode-go`](https://www.npmjs.com/p | Capability | `dsh-opencode-go` | `dsh-opencode-patch` (this plugin) | | :-- | :-- | :-- | | **Role** | Standalone Go provider | Universal gateway patch & enhancement layer | -| **Intercepted routes** | Dedicated Go route only | Any claimed route: `opencode`, `opencode-go`, custom relays | -| **OpenCode Go models** | ✅ (`/zen/go/v1`) | ✅ (`/zen/go/v1`) | -| **OpenCode Zen models** | ❌ | ✅ (`/zen/v1` — Claude, GPT-5, Gemini, contributor) | -| **Multi-protocol gateway** | OpenAI Completions only | Responses + Completions + Anthropic + Mistral, from the SDK each model declares | +| **Intercepted routes** | Dedicated Go route only | Serves `opencode` and `opencode-go` itself; decorates any claimed relay route too | +| **OpenCode Go models** | ✅ (`/zen/go/v1`) | ✅ (`/inference/go/…`, or `/zen/go/v1` with `endpoint: legacy`) | +| **OpenCode Zen models** | ❌ | ✅ (`/inference/…`, or `/zen/v1` with `endpoint: legacy` — Claude, GPT-5, Gemini, contributor) | +| **Multi-protocol gateway** | OpenAI Completions only | Completions + Responses + Anthropic + Google on each plane's own route, from the SDK each model declares | | **Free-tier tool fallback** | ❌ | ⚠️ Injects `read` + `bash` schemas automatically for the models that ARE served. The schemas are the CLI's own field names and sets (`path`/`offset`/`limit`, `command`/`workdir`/`timeout`/`background`), because the gateway validates the shape. **Zen's free tier is not served from here** — measured with this machine's session token, its active org and every header captured from the real client, a free-tier Zen model still answers `403 FreeTierError` on both endpoint families. The Go plane has no free tier to gate, so its free models are served by the subscription key; Zen's `-free` ids are offered as well and answer with the gateway's own refusal | | **Hierarchical subagents** | ❌ | ✅ Parent-session headers injected | | **Dynamic workspace project** | ❌ | ✅ Derived from `session.header.cwd` | @@ -171,8 +171,8 @@ OpenCode's gateway `GET …/models` endpoints frequently return a truncated subs 1. Dual bundled shims (zero latency, offline): `OPENCODE_GO_CATALOG` carries every active Go subscription model with per-million-token rates, so session pricing works before the first refresh; `OPENCODE_ZEN_CATALOG` carries the flagships (`claude-sonnet-4-5`, `claude-opus-4-7`, `gpt-5.4`, `gemini-3.8-flash`, `qwen3.8-max`, `kimi-k3`). The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file, and CI checks the shim for drift: a pull request that changes the generator (or what it loads) fails on a stale shim; any other pull request, and a push to main, warns, and the nightly run proposes the refresh. Retired models are excluded so a failed refresh can never resurrect a row the gateway no longer serves: the three Zen-route Muse Spark 1.2 ids are suppressed, while the paid Go 1.2 contributor entry stays (the CLI still lists it). Startup is instant: no cold-start delay, blocking network calls, or airplane-mode failures. 2. Background revalidation: both catalogs revalidate against [`https://models.opencode.ai/api.json`](https://models.opencode.ai/api.json) — the OpenCode catalog, which today serves the byte-identical object as `models.dev/api.json` — every **60 minutes** (the OpenCode CLI's canonical cycle), merging new models, deprecations and updated limits. Errors degrade gracefully and retain the active catalog. 3. Gateway models-endpoint enrichment: `patchFetch` intercepts `GET …/models` on OpenCode routes and merges the live Go or Zen catalog: human-friendly names (`DeepSeek V4.1 Flash`, `Qwen3.8 Flash`, `Grok 4.7`, `MiMo V2.6 Pro`), verified context windows (up to 1,000,000+ tokens) and max output tokens (up to 384,000), correct input modalities (`text`, `image`), with retired Muse Spark 1.2 rows omitted. -4. Settings “Fetch Available Models” decoration: DSH asks the route's own adapter first, and for an installed `opencode` route `llm-pi-ai` answers from its packaged catalog without calling the gateway. The plugin therefore decorates the hosted discovery result: adapter rows and order are preserved, their metadata enriched from the catalog, provider-retired rows removed, and **models whose protocol DSH cannot speak dropped** (offering one could only fail). It **does not append** catalog rows the adapter did not report: an earlier revision did, and that is how a deployment that had declared four models saw eighty-six in Settings. This is candidate metadata for the settings surface; it never rewrites saved route configuration. Widening a deployment's list is a separate, explicit action — the card's **Check catalog** row, which shows what the catalog offers and declares it only when asked. -5. Native model discovery registration: on the host runtime the plugin also registers with `ctx.llm.registerModelDiscovery` for `opencode-go` and `opencode`, which is what a _draft_ route's “discover” call answers from — not the picker, which reads the declared list. The gateway-listing enrichment, this registration, the discovery decoration and the model-info decoration all sit behind the **Enrich model metadata from models.dev** switch; the adapter decoration and the Responses-route hiding do not. +4. Settings “Fetch Available Models” decoration: Settings discovery is keyed by `llm-pi-ai`'s settings namespace, so for a plane id `llm-pi-ai`'s own discovery callback answers, from pi's packaged catalog without calling the gateway; no adapter is consulted, the plane adapter included. The plugin's `discoverModels` wrapper (`decorateModelDiscovery`) then decorates that hosted result: adapter rows and order are preserved, their metadata enriched from the catalog, and provider-retired rows removed. Nothing is dropped for its protocol: every SDK the catalog uses maps to one. It **does not append** catalog rows the adapter did not report: an earlier revision did, and that is how a deployment that had declared four models saw eighty-six in Settings. This is candidate metadata for the settings surface; it never rewrites saved route configuration. Widening a deployment's list is a separate, explicit action — the card's **Check catalog** row, which shows what the catalog offers and declares it only when asked. +5. Native model discovery registration: on the host runtime the plugin also registers with `ctx.llm.registerModelDiscovery` for `opencode-go` and `opencode`, which is what a _draft_ route's “discover” call answers from — not the picker, which reads the declared list. Its rows are the catalog's servable models (`toDiscovered`), which today is all of them. The gateway-listing enrichment, this registration and the discovery decoration all sit behind the **Enrich listings from the OpenCode catalog** switch (`enrichModels`). The model info a plane answers is not a decoration: the plane adapter builds every model from the user's row and the catalog (D1, D17), whatever the switch says. ### 8. Session spend & model rate @@ -186,7 +186,7 @@ Behind the **Enable Composer Usage Meter** switch (on by default): ### 9. Key resolution per routed model & overage behavior -Different models may route to different accounts (a corporate Go subscription alongside a personal Zen key). `discoverGoConfig(ctx, provider)` reads the loaded Cordis row for the `apiKeyEnv` / literal `apiKey` assigned to the route, `resolveGoApiKey` resolves it under this plugin's own `keySource` setting, and **the route decides which account is queried: not the shape of the key**. The prefix test that used to sit here was removed once it was measured: OpenCode issues `oc_sk_…` for Go credentials too, so the string says nothing about the route in use. The meter queries accordingly: +Different models may route to different accounts (a corporate Go subscription alongside a personal Zen key). `discoverGoConfig(ctx, provider)` reads the loaded Cordis row for the `apiKeyEnv` / literal `apiKey` assigned to the route, `resolveGoApiKey` resolves it under this plugin's own `keySource` setting, and **the route decides which account is queried: not the shape of the key**. The prefix test that used to sit here was removed once it was measured: OpenCode issues `oc_sk_…` for Go credentials too, so the string says nothing about the route in use. (A turn on a plane is authenticated differently, by `llm-pi-ai`'s own rule (D20): the entry's `apiKeyEnv` through the credentials service, a Sign-in record, then pi's own lookup, which reads `OPENCODE_API_KEY` on BOTH planes. `OPENCODE_GO_API_KEY` reaches a Go turn only as the Go entry's `apiKeyEnv`, the named-reference path, so a Go entry that names none sends the Zen key. `keySource` orders only what the meter and the request patch read.) The meter queries accordingly: - OpenCode Go (`opencode-go`): fixed subscription quotas across three windows (5-hour rolling, weekly, monthly %). At 100% the gateway answers `GoUsageLimitError` (HTTP 402/429). Server-side overflow into Zen balance works only if that Go account has _Use balance_ enabled ([opencode.ai/workspace/go](https://opencode.ai/workspace/go)): a Zen key on a _separate_ account is never debited automatically. - OpenCode Zen (`opencode`): per-token pay-as-you-go against the account balance; no rolling windows. At $0.00 the gateway returns `HTTP 402 Insufficient account funds`: top up via the Console link in the popover. diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 2ba93fcf..4c1234f2 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -25,7 +25,7 @@ Phases land as **stacked pull requests**. Each one is based on the previous phas | P-T1a | `build:` pin the host | in review | | P-T1b | `test:` a harness that is the real host | in review | | P-H | `refactor:` one way to patch a host method | in review | -| P-R1 | `feat:` the plugin owns the plane routes | planned | +| P-R1 | `feat:` the plugin owns the plane routes | in review | | P-R2 | `fix:` the endpoint family, measured | planned | | P3 | `feat:` Settings complements pi's list | planned | | P-T2 | `test:` suite structure | planned | @@ -151,6 +151,7 @@ Phases land as **stacked pull requests**. Each one is based on the previous phas Three corrections to the facts above: - **F12, the mechanism.** Cordis passes listener arguments unwrapped (the harness's `vendor/cordis/src/utils.ts:118-125`). The first prototype's in-place trim failed because the fiber keeps the waterfall's return value, not because of a proxy. +- **F12, the constraint it places on the host (P-R1 review).** `Fiber.update(config, noSave = false)` hands the RESOLVED value to `internal/update`, where the loader writes it to the row's options and the profile unless `noSave` is set (`vendor/cordis/src/fiber.ts:736-753`, `vendor/loader/src/index.ts:115-121`). So a `piFiber.update(raw)` with `noSave` false would persist the stripped copy and delete both planes from the user's file. No caller does that today: the editor writes the raw value, and the loader's own update passes `true` (`vendor/loader/src/config/entry.ts:101-107`); the same path would already write interpolated `!!js` values for any plugin. The constraint is recorded at the listener (`src/host/route-claim.ts`); a guard waits for a real caller. - **F13, the read view.** Settings writes the same file, but its read view (`describe().value`) loses the plane entries. The plugin grafts them back (D18, F20). - **Run S0's other routes.** `deepseek-official` and `deepseek-account` belong to the `llm-deepseek-*` plugins, not to pi-ai. @@ -358,7 +359,7 @@ What stays private is pinned by contract tests against the real host packages: - The `@deepseek-ai/*` packages move to rc.2, and the devDependencies gain `dsh-llm`, `dsh-app-boot`, `dsh-config-editor`, `dsh-settings` and `cordis-plugin-loader`. - Add `test/support/host-versions.ts`, with a version test: `loadHostPi().version` is 1.1.0, and the root pi is the same copy. - Rewrite `mount-protocols.test.ts` as "every catalog SDK maps to a protocol that pi's `api/*` exports implement", keeping the row that pi 1.1.0's Go provider lacks Google. -5. **P-T1b `test:` the real-host harness.** Additive only; in review. It landed `test/support/host.ts` (tier A: `Context` + `LlmRuntime` + pi-ai; tier B: app-boot `boot()` with a temporary HOME), the contract tests (`host-waterfall`, `host-shapes`, `host-credentials`, `host-editor-contracts`), the tagged characterization (`host-characterization`, with `S0_TODAY` shared by the binary e2e), the keyless matrix, the probe bundle and the `e2e-host` job. Two departures from the phase as first planned, both deliberate: tier B's rows carry package names through a stub module loader rather than `cordis:` builtins, because the plugin finds pi-ai by `options.name` (`piAiEntry` in `src/responses-provider.ts`); and tier B fixes the apply order (`plugin-first`, the binary's, or `pi-ai-first`), because the loader starts rows in parallel. Two narrower ones: the `e2e-host` PR scope is wider than first named (the job's own inputs, and until P-R1 the `src` modules that install the host patches and mounts whose behaviour depends on the real loader and apply order), and the floating cell skips the rows that depend on which ids pi describes. +5. **P-T1b `test:` the real-host harness.** Additive only; in review. It landed `test/support/host.ts` (tier A: `Context` + `LlmRuntime` + pi-ai; tier B: app-boot `boot()` with a temporary HOME), the contract tests (`host-waterfall`, `host-shapes`, `host-credentials`, `host-editor-contracts`), the tagged characterization (`host-characterization`, with `S0_TODAY` shared by the binary e2e), the keyless matrix, the probe bundle and the `e2e-host` job. Two departures from the phase as first planned, both deliberate: tier B's rows carry package names through a stub module loader rather than `cordis:` builtins, because the plugin finds pi-ai by `options.name` (`piAiEntry`, then in `src/responses-provider.ts`, since P-R1 in `src/host/host-modules.ts`); and tier B fixes the apply order (`plugin-first`, the binary's, or `pi-ai-first`), because the loader starts rows in parallel. Two narrower ones: the `e2e-host` PR scope is wider than first named (the job's own inputs, and until P-R1 the `src` modules that install the host patches and mounts whose behaviour depends on the real loader and apply order), and the floating cell skips the rows that depend on which ids pi describes. The `hasProviderApiKey` baseline (`api/session-controller/src/catalog.ts:97-116`) is a **restatement**: no installed copy reaches the function (the published `@deepseek-ai/dsh-api-session-controller` entry exports `SessionController`, `buildModelCatalog` and a few classes but not it, its tarball ships `lib/` and no `src/`, and the package is not a dependency here in any case), so `test/support/host.ts` restates it (`providerWithApiKey`) from the real parts tier B holds — `settings.describe({ redactSecrets: true })`, `llm.listConfigurableProviders()` and `credentials.describe` — keeping upstream's `credentialRef` grammar check as a `TypeError` (a host-editor-contracts case pins it), answering the provider id where upstream answers `true`, and omitting the `RemoteError` for a missing service tier B always has; both `host-editor-contracts` and `host-characterization` use it. The cases in `host-editor-contracts` boot WITHOUT the plugin, so they are host contracts that nothing in `src/` can move: with only the plane keys stored it answers true (the Zen plane); under a test-local strip listener it answers false, because the planes' `describe().value` is gone (F20), which upstream makes `initializeDefaultModel` replace the user's default model. That false is the host fact the D18 wrapper exists to counter, not a row any phase flips. The guard on the plugin is the `[BASELINE]` row in `host-characterization`: today's plugin as it ships, with only the plane keys stored, in both apply orders, answers the Zen plane, and pi-ai's `settings.describe` value lists both planes. P-R1 must keep that row green while its own strip is active. @@ -389,21 +390,8 @@ What stays private is pinned by contract tests against the real host packages: - **The plane adapters' composition.** pi-ai serves both planes from one adapter, so the per-route wraps became one patch per (adapter, method) that composes the adapter's plane routes in registry order. That keeps today's nesting, including the pi-ai-first `[INTENDED_CHANGE]` row where the Go plane's repaired base is Zen's. - **Re-wrap built, not wired.** `rewrapOnAdaptersUpdated` is tested but used by no site: on `decoratePlaneAdapters` it would wrap pi-ai's adapter in the plugin-first order and flip the `[KNOWN_DEFECT]` rows, and P-R1 deletes that site. - **Left for P6:** the global `fetch` patch in `lifecycle.ts` stacks the same way (A, B, dispose A, dispose B leaves A's patched fetch installed), but it is a global, not a host service method. -7. **P-R1 `feat:` the plugin owns the plane routes.** - - **New:** - - the design above; - - D1, D16, D17, D19 and D20; - - the `settings.describe` wrapper; - - `utils/protocols.ts`. - - **Deleted:** the hidden-route machinery and the D19 settings. - - **Tests:** - - retarget `protocol-routing.e2e.ts` and `dispatch-levels.e2e.ts` to the planes; - - move the per-file coverage floors to the new modules, at 100 for the pure ones, and check that every floor's glob matches a file. - - **Docs:** - - the README, including "Removing or disabling the plugin" and "Changed in 1.x"; - - `docs/redirect-planes.md` §8-9 and `docs/protocol-routing-and-merge.md`; - - AGENTS.md's repo map and CONTRIBUTING.md. - - **The PR body** names every tagged row it flips. +7. **P-R1 `feat:` the plugin owns the plane routes.** In review. Landed as the design above (D1, D3, D5, D8, D13, D15-D21): `src/host/route-claim.ts`, `src/host/plane-provider.ts`, `src/host/describe-graft.ts` and `src/host/host-modules.ts` over the pure `src/utils/protocols.ts`, `model-metadata.ts`, `plane-model.ts` and `plane-profile.ts`, with `inject: []` at the root. Deleted: `responses-provider.ts`, `responses-routes.ts`, `google-bridge.ts`, `pi-provider.ts`, `utils/google-replay.ts` (a mock-gateway replay showed one provider label), the listing hide, the stream redirect, the model-info and plane-adapter decorations, and the D19 settings. Nine deliberate refinements of the text above: the describe wrapper is its own module on `patchMethod`; the host-module seam sits in `cordis-context.ts` with the loaders in `host/host-modules.ts`, and pi-ai's module is taken from its own loader entry during its commit run; the libraries (`loadHostPi`'s pi, dsh-llm, the loader) are found from the wrapper the loader resolves from the context's base URL, never from the plugin's own install, so a `link:`ed checkout runs on the host's copies too (the binary e2e asserts the pi root is the host prefix's); D20 mirrors pi-ai's exact rule (the launch environment only when no credentials service is mounted); `legacy` takes pi's own base only when pi files the model under the same protocol on a `/zen` root; cost is catalog > pi > zero, because pi-ai's model row has no `cost` (D17's table puts it under the user's row), and nothing replaces `modelRoutes`' per-model protocol pin — the route-level `api` covers only ids neither the catalog nor pi describes; only one pi-ai row's planes are stripped and served — the served row is sticky while it is live and declares a plane, and otherwise the first live row whose raw config declares one, else the first live row, so a re-enabled row that cordis appends to the end of the registry is still the one served, and the served row never moves between two live declaring rows, which would leave the one left behind stripped with nobody serving its planes — and another pi-ai row keeps and serves its own, reported once; when the served row changes (it was disposed or stopped declaring), what was built from the previous one is released and a new served row that already holds a plane is restarted once, as the mount does; the teardown and the stand-down wait for a LOADING pi-ai to settle and restart it if it resolved without a plane it declares (the text said "with pi-ai ACTIVE"), and the stand-down releases the plugin's handle first and happens whether or not a plane is declared, so a plane a later Settings save adds goes to pi-ai's own save check rather than to a refusal no retry can clear; and the characterization's fixture-sanity case derives `info` as D17 does, so `INFO_NOT_FROM_CATALOG` stays empty. The profiles' memo key holds the catalog's generation and a landed refresh re-runs the reconcile, so the live catalog reaches the profiles after boot without a plane edit. The PR body names every tagged row it flips. + - **From the P-R1 review.** (a) **D1 for an effort list that names `none`:** on Chat Completions the `none` is Off's WIRE value (`thinkingLevelMap.off = "none"`, profile `reasoningEfforts.off = "none"`), as pi 1.1.0's own entries carry it, so Zen `mistral-large-4` and Go `hy3`/`hy4-preview` keep offering Off and send `reasoning_effort: "none"` for Off and for a turn with no level — what pi-ai served before P-R1. On Responses a listed `none` still offers no Off: pi's own entries disagree there (gpt-5.x pin `off: null`, gpt-6 carry `"none"`), a string `off` would become the request every no-level turn sends, and `opencode/gpt-5.4`'s `efforts` facet is `[BASELINE]` without Off, so it is the owner's call. **Open for the owner, not landed:** a `toggle` beside an `effort` list (Zen/Go `claude-haiku-5-5`, `deepseek-v4*`, `kimi-k3`, `qwen3.8-*`) offers the list's levels without Off, as the plugin did before P-R1 (`[BASELINE]`); pi alone offers Off on `claude-haiku-5-5`. Extending D1 to offer Off there moves `[BASELINE]` facets and changes the no-level request on Anthropic and Responses, so it needs a decision and its own characterization flips. (b) **D20 on the Go plane:** pi's lookup reads `OPENCODE_API_KEY` on both planes, so a Go entry without `apiKeyEnv` sends the Zen key on every protocol; before P-R1 Go's Responses, Messages and Google defaulted to `OPENCODE_GO_API_KEY`. D20 stands as written; the README's "Changed in 1.x" names the change and step 3 tells a Go user to name the reference, and `test/host-planes.test.ts` pins both cases. Changing the Go default would amend D20. (c) **A Settings save is judged with the last modules that loaded**, so a valid save in the window after a handover or pi-ai's partial dispose is not refused for timing; only a save before the mount's first load is. (d) **The Models store's join is a restatement**, like `hasProviderApiKey`: `modelsPageRows` in `test/support/host.ts` and the probe's `modelsJoin` restate `configured` and `apiKeyEnvOf` from `client/ui-settings-models/src/client/store.ts:170-181,247-263` at the pinned cluster tag (`dsh-v0.2.1-rc.2`), over the real host's `settings.describe({ redactSecrets: true })` and `listConfigurableProviders()` in tier B and on the binary. The client package is not added as a dependency: it would pull a client UI package into a node test for a three-line join. 8. **P-R2 `fix:` the endpoint family, measured.** - Probe 3 URL shapes × 4 protocols × 2 planes, keyless, plus keyed probes per family with the CI keys. - Set the default family and pin it with a test. If a `modern` cell fails, flip the default; `endpoint: legacy` still maps every protocol except Go's Google, which stays on `/inference/go/google/v1beta` under every family (`GO_GOOGLE_BASE_URL`). Decide here whether `endpoint: legacy` is a guarded configuration: the keyless matrix records its Responses and Messages cells and Zen's Google cell but does not assert them. @@ -582,7 +570,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 `src/host/**`, `src/lifecycle.ts`, `src/identity.ts`, the modules that install the host patches and mounts whose behaviour depends on the real loader and apply order, until P-R1 moves them under `src/host/` (`src/responses-provider.ts`, `src/responses-routes.ts`, `src/models-discovery.ts`, `src/google-bridge.ts`, `src/fetch-patch.ts`, `src/pi-provider.ts`, `src/stream-hook.ts`; every other row producer, such as `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/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:** @@ -594,11 +582,11 @@ P0 → P1a → P1b → P2 → P-T1a → P-T1b → P-H → P-R1 → P-R2 → P3 **Cells:** - (a) pi 1.1.0, pinned and patched, on PRs, nightly and on dispatch; it asserts every row; -- (b) whatever npm installs, nightly and on dispatch. It skips the rows that depend on which ids pi describes (the keyed listing and the per-model rows) and still asserts the save rows. Those assume the floating pi lacks at least one of `ZEN_UNDESCRIBED` and still describes every other declared Zen id, so a pi that learns all of them or retires a declared id turns them red by design. Its probe JSON is the record. +- (b) whatever npm installs, nightly and on dispatch. Since P-R1 it skips only the per-model rows (a newer pi's own entry can move a model's metadata); the listings before and after a turn, the save rows and the row that the plane provider is built from the host prefix's own pi are asserted in both cells, since none of them depends on which ids pi describes. Its probe JSON is the record. ### The live gateway job (`OPENCODE_API_KEY` / `OPENCODE_GO_API_KEY`) -- **Every PR:** the keyless matrix. A 404 means the path is gone; a 400 "Model is unavailable" or a 410 means the model is gone. It asserts that every cell has a model and was sent, and that no cell today's plugin dispatches to with the default endpoint (`auto`, an alias of `modern`: legacy completions; modern Responses, Messages and Google) nor P-R1's modern completions target answers 404; the other legacy verdicts are P-R2's record. `endpoint: legacy` users dispatch Responses and Messages on both planes, and Zen's Google, to the legacy cells, which are recorded for P-R2, not asserted; Go's Google stays on `/inference/go/google/v1beta` under every family (`GO_GOOGLE_BASE_URL`), the modern cell already asserted. +- **Every PR:** the keyless matrix. A 404 means the path is gone; a 400 "Model is unavailable" or a 410 means the model is gone. It asserts that every cell has a model and was sent, and that no cell the plugin dispatches to with the default endpoint (`auto`, an alias of `modern`: since P-R1, all four protocols on the modern family) answers 404; the legacy verdicts are P-R2's record. `endpoint: legacy` users dispatch completions, Responses and Messages on both planes, and Zen's Google, to the legacy cells, which are recorded for P-R2, not asserted; Go's Google stays on `/inference/go/google/v1beta` under every family (`GO_GOOGLE_BASE_URL`), the modern cell already asserted. - **PRs that touch `src/host/**`, and nightly:** minimal keyed probes, one per plane × protocol, with a small `max_tokens`. - **Nightly and on dispatch:** the full keyed suites. diff --git a/docs/opencode-parity-plan.md b/docs/opencode-parity-plan.md index 923234c1..b4d9f633 100644 --- a/docs/opencode-parity-plan.md +++ b/docs/opencode-parity-plan.md @@ -1,5 +1,7 @@ # OpenCode Implementation Parity & Architecture Specification +> **Historical: this document does not describe the current tree.** Its routing rows — `internalRouteFor`, the mounted `opencode-responses`/`opencode-anthropic`/`opencode-google` child routes, `maxContextWindow`, `decorateModelInfo`/`decoratePlaneAdapters`, `src/responses-routes.ts`, `src/responses-provider.ts`, `src/google-bridge.ts` — were superseded by P-R1, which serves both planes on their own routes through the plugin's plane adapter; read [protocol-routing-and-merge.md](./protocol-routing-and-merge.md) and [redirect-planes.md](./redirect-planes.md) instead. Its login rows (`console-auth.ts`, `login-flow.ts`, `token-store.ts`, the OAuth precedence) describe `v2` work that is not in `main`; [credentials.md](./credentials.md) says what is. Kept as record, not as specification. + This document defines the complete architectural parity plan between `dsh-opencode-patch` and the official OpenCode CLI engine (`opencode`). It details how the DeepSeek Harness (DSH) integration achieves full protocol, credential, session, and catalog fidelity, and establishes the contracts for data model separation and domain boundaries. --- diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index 8d7ad753..fc11d638 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -4,52 +4,45 @@ How this plugin makes OpenCode's models speak the protocol each of them is actua ## 1. Why routing exists -OpenCode serves its models on **four different API shapes**, and which one a model lives on is a property of the model, not of the account. The per-plane counts, and the tables that go with them, live in [`redirect-planes.md`](./redirect-planes.md) §2; what follows is why the plugin has to care. Measured 2026-10-17: +OpenCode serves its models on **four different API shapes**, and which one a model lives on is a property of the model, not of the account. The per-plane counts, and the tables that go with them, live in [`redirect-planes.md`](./redirect-planes.md) §2; what follows is why the plugin has to care. Measured 2026-10-17, shown on the default (modern) family: -| API shape | Wire endpoint | Which models | Signal in the catalog | +| API shape | Wire endpoint (Zen) | Which models | Signal in the catalog | | :-- | :-- | :-- | :-- | -| OpenAI **Chat Completions** | `POST /zen/v1/chat/completions` | **the default**: 24 of 81 | the model names **no** SDK | -| OpenAI **Responses** | `POST /zen/v1/responses` | 31 of 81 | `@ai-sdk/openai` | -| Anthropic **Messages** | `POST /zen/v1/messages` | 17 of 81 | `@ai-sdk/anthropic` | -| Google **Generative AI** | `POST /zen/v1/models/:streamGenerateContent` | 8 of 81 | `@ai-sdk/google` | -| Mistral **Conversations** | `POST /zen/v1/chat/completions` | 1 of 81 | `@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.test.ts`, because a count written into prose does not regenerate with the data it counts. +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. -**The original route's own format is the case that does nothing.** A model that speaks the route's protocol names no SDK, because that protocol IS the default; the route the user configured already speaks it, so the call stays there. No hidden route, no redirect, no merge — which is why a deployment whose models are all completions mounts nothing at all, and why the whole apparatus below exists only for the models that are not. +**"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. -**"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 default is not "OpenAI" either. It is whatever api the route itself was registered with, and a model that names no SDK speaks it. +**Mistral is completions (D16).** `@ai-sdk/mistral` was once kept unserved for want of a measured dialect. pi's own installed data files `mistral-large-4` under `openai-completions`, so the plugin follows pi, and no SDK is withheld any more. -DSH picks a transport from the provider row's protocol. If that protocol is not the one the model is served on, the gateway does not fall back. It answers `500`. So the plugin's job is to send each model to the endpoint its own SDK names, without asking the user to configure anything. +## 2. How a model is served: one route per plane (D13) -**One SDK is deliberately not served, and it is Mistral.** Its models post to the completions path while speaking a different body, so path alone would route them wrongly, and the dialect the gateway answers with has not been measured — an earlier probe could not observe the request it produces. They stay unoffered rather than being sent somewhere invented, and `UNSERVED_SDKS` records the decision. +Since P-R1 the plugin serves `opencode` and `opencode-go` itself, with **one adapter for both planes**, and every protocol rides on the plane's own route. There is no hidden route, no redirect and no listing hide. -`@ai-sdk/google` used to be in that position, and is not any more: pi implements `google-generative-ai`, the plugin serves it through its own adapter on the route `opencode-google`, and the live suite probes that path on every run. The two mechanisms that serve the four shapes are described below. - -**Mistral shares the completions PATH but not its protocol.** `@ai-sdk/mistral` posts to `/chat/completions`, the same URL the completions models use, while speaking a different body and reading different responses. Path alone would therefore route it wrongly, which is why the route is chosen from the SDK and the protocol, and why the e2e probe has to send a real request before any of it counts as supported. - -## 2. How the route is chosen - -Three steps, all data-driven: - -1. The catalog carries the SDK. Every model row in `src/catalog-data.ts` has a `provider_npm` field (`@ai-sdk/openai`, `@ai-sdk/anthropic`, …), generated from models.dev by `scripts/regenerate-catalog-shim.ts`. -2. The SDK names a protocol. `src/responses-routes.ts` holds the only table that maps one to the other: +1. **The catalog carries the SDK.** Every model row in `src/catalog-data.ts` has a `provider_npm` field, generated from models.dev by `scripts/regenerate-catalog-shim.ts`. +2. **The SDK names a protocol.** `PROTOCOL_FOR_SDK` in `src/utils/protocols.ts` is the only table that maps one to the other, and a row naming no SDK speaks completions: ```ts const PROTOCOL_FOR_SDK = { "@ai-sdk/openai": "openai-responses", "@ai-sdk/anthropic": "anthropic-messages", "@ai-sdk/google": "google-generative-ai", + "@ai-sdk/mistral": "openai-completions", }; ``` -3. The protocol names a route. `internalRouteFor(provider, model, providerNpm)` answers which route serves that model (`opencode-responses`, `opencode-anthropic`, `opencode-google`), and only for providers in `COMPLETIONS_ROUTES`, the routes this plugin claims. Anything else returns `undefined` and is left alone. - -`src/responses-provider.ts` then **mounts the host's own `llm-pi-ai`** under isolated auth scopes to serve those routes. That is the piece with four host contracts to survive; read its header before touching it. The plugin registers the routes, so the user configures nothing. +3. **The plane builds the model.** `planeModelFor` (`src/utils/plane-model.ts`) folds one declared model into the pi model object the adapter serves: its protocol from the catalog, then pi's installed entry, then the plane entry's `api`, then completions (D16); its base per protocol on the chosen endpoint family; its name, window, limits, modalities and cost from the user's row first, then the catalog, then pi (D17); and its reasoning from the user's `reasoningEfforts`, then the catalog (D1). `planeProfileFor` (`src/utils/plane-profile.ts`) does the same for the whole plane entry, with `llm-pi-ai`'s own defaults and its own validation messages, and never throws. +4. **The plane adapter serves it.** `src/host/plane-provider.ts` builds `PlaneAdapter`, a subclass of the host's own `PiAiAdapter`, over a provider made with pi's own `createProvider` and all four of pi's protocol implementations, each wrapped with pi's session header. pi's provider dispatches per model (`model.api`), so one route speaks every protocol. +5. **The route claim keeps `llm-pi-ai` off the planes.** `src/host/route-claim.ts` prepends an `internal/config` listener that hands pi-ai's fiber a copy of its configuration without the two plane entries, so pi-ai never registers them; it then registers `PlaneAdapter` for exactly the planes the profile declares, and `handle.replace`s the set when it changes. An edit of a plane in Settings is validated with `llm-pi-ai`'s own checks before it is written, and `src/host/describe-graft.ts` grafts the committed plane entries back into `settings.describe()`, so _Settings → Models_ still reads and writes them where they always were (D18). -### Why a provider cannot serve mixed protocols, in the harness's own terms +### Why a pi-ai route cannot serve mixed protocols, and why the plugin's can -Traced through `dsh-llm-pi-ai` and pi-ai, because "one provider, many protocols" keeps looking like it should work and never does: +Traced through `dsh-llm-pi-ai` and pi-ai, because "one provider, many protocols" keeps looking like it should work on pi-ai's own route and never does: ``` profile → PiAiProviderProfile @@ -60,44 +53,11 @@ profile → PiAiProviderProfile → dispatch @earendil-works/pi-ai/dist/models.js:553 ``` -`createProvider` calls `input.api.stream(model, …)` — the ROUTE's implementation, so a route with an `api` speaks exactly one protocol and `model.api` is ignored. pi's own provider _does_ dispatch per model (`apiFor = single ?? byApi[model.api]`), and pi ships `opencode` with an api MAP of four protocols (`providers/opencode.js`), so the reused-catalog path genuinely supports mixed protocols — for models the installed catalog describes. - -**The wall is model materialization** (`lib/index.js:663-690`): - -```js -const base = defaults.get(entry.id); // the installed catalog entry -const api = request.api ?? base?.api ?? routeApi; -if (api === void 0) invalid(provider, "… needs an api …"); -``` - -with `routeApi = sharedCatalogApi(defaults)`, which answers the one protocol the route's shipped models agree on — **and `undefined` when they disagree**, which is always the case for `opencode`. - -| declared model | protocol it gets | outcome | -| :-- | :-- | :-- | -| described by the installed catalog | `base.api` (its own) | per-model dispatch, no help needed | -| hand-written, not described | `request.api`, else `routeApi` | **INVALID_CONFIG** when both are absent | +`createProvider` with a single `api` calls the ROUTE's implementation, so a route with an `api` speaks exactly one protocol. pi's own provider _does_ dispatch per model (`apiFor = single ?? byApi[model.api]`), and pi ships `opencode` with an api MAP of four protocols, so the reused-catalog path supports mixed protocols — for models the installed catalog describes. -So: a route that names `api` pins every model on it; a route that does not name one gives hand-written models no protocol at all. That is why this plugin's answer is the three steps it implements rather than a single mixed-protocol provider: +**The wall is model materialization** (`lib/index.js:663-690`): a hand-written id resolves to `request.api ?? base?.api ?? routeApi`, and `routeApi` is the one protocol the route's shipped models agree on — `undefined` for `opencode`, because they do not agree — so a hand-written id with no `api` is `INVALID_CONFIG`. -1. **declare the route again, hidden** — an internal route per protocol, with the right `api` and `baseURL` (`registerResponsesProvider`); -2. **hide the mount** — `hideResponsesRoute` drops it from every listing, including the configurable-provider set and the browser catalog; -3. **merge at the presentation layer** — discovery and `/models` enrichment describe the models on the route the user actually selected, never on the hidden ones. - -### When the automatic path is enough, and when it never is - -pi-ai's own `opencode` provider carries a four-protocol api map, so it dispatches per model natively. Reusing it instead of mounting routes needs three things at once, and a deployment loses the argument if it fails any of them: - -| condition | note | -| :-- | :-- | -| every declared id is in pi's installed snapshot | a hand-written id has no `base.api` | -| the route declares no `api` | a declared `api` pins every model on the route | -| pi's per-model `baseUrl` is current | the installed snapshot still says `…/zen` | - -The third is ours to fix and is fixed: the adapter decoration writes `baseUrl` (and `api`) on the model it answers, and pi passes that exact object down (`provider.streamSimple(requestModel, …)`), so the override reaches the wire. - -The first two cannot be fixed from a plugin. A hand-written id resolves to `request.api ?? base?.api ?? routeApi`, and `routeApi` is `sharedCatalogApi` — the one protocol the route's shipped models agree on, which is `undefined` for `opencode` because they do not agree. A route that names `api` pins every model on it instead. So: **any deployment that hand-writes a model needs a route that speaks its protocol**, which is why the hidden-route mechanism is structural rather than a workaround, and why the mount narrows to the declared ids — a deployment whose ids all speak the route's protocol mounts nothing at all. - -And the consequence for anything built on top: a model's protocol is not a property of the model object alone. It is the route's, chosen at profile resolution, so a model that needs a different protocol needs a route that speaks it. +The plane adapter goes around the wall rather than through it: it builds its own provider with the four-protocol api map (the shape pi's `opencode` provider has), and its own model objects, so a model's protocol comes from the catalog for every id, described or not. That is also why any saved id is served (D15): one the catalog does not describe is completions unless its row says otherwise. ### Why it is a table and not a model list @@ -105,9 +65,9 @@ An earlier version kept a hand-written list of which models redirect. A hand-pat ### The end-to-end test is the only real check -`test/e2e/protocol-routing.e2e.ts` asks the gateway which endpoint recognises each shipped model. The unit tests read the same mapping they verify, so they cannot catch a stale mapping. Only the live gateway can. A wrong-endpoint cell answering `500` is what pins the route; a `403 FreeTierError` only proves the gateway parsed the model. +`test/e2e/protocol-routing.e2e.ts` asks the gateway which endpoint recognises each shipped model, and reads the protocol and base it expects from `planeModelFor`. The unit tests read the same mapping they verify, so they cannot catch a stale mapping. Only the live gateway can. A wrong-endpoint cell answering `500` is what pins the protocol; a `403 FreeTierError` only proves the gateway parsed the model. -### One base URL per plane is one base too many +### The base URL is per protocol, not per plane pi builds each protocol's request differently. The OpenAI protocols append `/chat/completions` or `/responses` to a base that already carries `/v1`; the Anthropic one hands its base straight to the Anthropic SDK, which appends `/v1/messages` itself. @@ -118,38 +78,33 @@ Measured both halves. Asking the SDK which URL a base produces: | `https://opencode.ai/zen` | `…/zen/v1/messages` | `400`, upstream reached | | `https://opencode.ai/zen/v1` | `…/zen/v1/v1/messages` | `404` | -So an internal route mounted on its plane's base asked for `/v1/v1/messages` and could not answer — which is every model whose protocol is `anthropic-messages`, the ids the two planes disagree about. The mount takes its base per protocol from the table that records this, and the test that guards it names the route, the protocol and both URLs when it fails. - -pi's own catalog agrees, which is the reason a table of bases is worth keeping rather than deriving: for `opencode` it says `…/zen` for `anthropic-messages` and `…/zen/v1` for the rest. +So `baseUrlFor` strips a trailing `/v1` from every Anthropic base, on both families and on a foreign `baseURL` alike; pi's own catalog agrees (`…/zen` for `anthropic-messages`, `…/zen/v1` for the rest). The table is pinned cell by cell in `test/plane-model.test.ts`, and every cell is sent to the offline gateway mock by `test/mock/plane-dispatch.mock.ts`. -### What the two layers of testing each prove +### What the layers of testing each prove -They are different checks, and neither stands in for the other: +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. Its bodies are written by the test, not produced by the plugin — the first Google probe was missing a `role` the Gemini body needs, and the gateway's refusal of that probe was a fact about the probe. -- The **unit suite** runs the plugin's own request path with the network intercepted: the real `PiAiAdapter`, this plugin's profile, pi's protocol implementation and the resolved credential, asserting the URL, the auth header and the body. It proves the request the plugin builds is one the endpoint accepts. - -Between them, a wrong endpoint and a wrong request are separate failures instead of one blurred claim. +- 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`, `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. ### How we support API endpoints in different formats -The user sees a single OpenCode provider and a single model list. Nothing in the interface names a route, a protocol or an endpoint, and there is no per-model setting to get wrong. That consistency is the product, and everything below it exists to keep it. - -What makes it possible is that the routing is **derived, never stored**: +The user sees a single OpenCode provider per plane and a single model list. Nothing in the interface names a protocol or an endpoint, and there is no per-model setting to get wrong. | Property | Why it holds | | :-- | :-- | -| One list | The merge (§3) appends canonical rows to whatever the adapter returned. A model's route is not part of what the user picks, so it cannot fall out of sync with the picker. | -| Computed per turn | From the model id: `provider_npm` → protocol → route. Nothing caches the decision, so a catalog refresh changes routing with no migration and no stored state. | -| Registered by the plugin, not the profile | `responses-provider.ts` mounts the host's own `llm-pi-ai` once per protocol under an isolated auth scope, so a profile that declares none of these routes still gets every endpoint. | +| One list | A plane lists the models its entry declares, and the whole catalog when it declares none (D3). A model's protocol is not part of what the user picks, so it cannot fall out of sync with the picker. | +| Computed from data | `provider_npm` → protocol → base, rebuilt whenever the plane entry changes. Nothing is stored, so a catalog refresh changes routing with no migration. | +| Registered by the plugin, configured in pi-ai | The plane entries stay in `llm-pi-ai`'s `providers` block; the plugin claims the two routes and leaves every other route to pi-ai. | | One place adds the headers | `fetch-patch.ts` writes the origin proof, the session id and the project attribution for every claimed route, so those do not vary by protocol. | Two guards keep the invisible half honest, because **a mechanism that silently drops a model looks exactly like a model that does not exist**: | Guard | What it catches | | :-- | :-- | -| `catalog.test.ts` | A generated catalog that names an SDK which is neither mapped nor listed as deliberately unserved. It fails and names the SDK. What it prevents is a vendor addition reaching the picker as a _missing_ model, which is how `mistral-large-4` disappeared once. | -| `protocol-routing.e2e.ts` | A protocol called routable without a live probe: a path and an auth convention it really sends. Mapping `mistral-conversations` failed this test before it failed anything else, which is the intended order: the table cannot outrun the evidence. | +| `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. | +| `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 @@ -180,8 +135,8 @@ What belongs here is only the part that is a routing consequence: the meter is a | Concern | Module | | :-- | :-- | -| Which endpoint a model is served on | `responses-routes.ts` | -| Serving that endpoint | `responses-provider.ts` | +| Which protocol and endpoint a model is served on | `utils/protocols.ts`, `utils/plane-model.ts`, `utils/plane-profile.ts` | +| Serving the planes | `host/route-claim.ts`, `host/plane-provider.ts`, `host/google-levels.ts`, `host/describe-graft.ts` | | Catalog merge | `models-catalog.ts`, `models-discovery.ts`, `catalog-data.ts` | | Allowance table | `go-limits-data.ts` (generated) | | Credentials and the `/usage` endpoint | `go-discovery.ts`, `key-capture.ts` | diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index a7ed34cb..59e139a8 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -10,8 +10,8 @@ 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 | `OPENCODE_API_KEY` | 81 served / 127 listed | -| Go (subscription) | `opencode-go` | the `endpoint` family's | `OPENCODE_GO_API_KEY` | 30 served / 34 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` follows the catalog, which today names `…/inference/openai/v1` and `…/inference/go/openai/v1`, and `legacy` pins the older `…/zen/v1` and `…/zen/go/v1`. Both are still served and they are entitled separately, which is why the choice is the user's rather than a migration. The dated measurement below was taken on the legacy family. @@ -23,35 +23,32 @@ A plane is a property of the **account and the base URL**, never of a model. The A **shape** is one wire protocol, named by the SDK a model declares in the catalog (`provider.npm`). A model naming no SDK speaks its route's own protocol, which is OpenAI Chat Completions. -| SDK in the catalog | Protocol | Zen | Go | -| :------------------ | :---------------------- | --: | ----: | -| _(none)_ | `openai-completions` | 24 | 19 | -| `@ai-sdk/openai` | `openai-responses` | 31 | **5** | -| `@ai-sdk/anthropic` | `anthropic-messages` | 17 | **5** | -| `@ai-sdk/mistral` | `mistral-conversations` | 1 | — | -| `@ai-sdk/google` | `google-generative-ai` | 8 | **1** | +| SDK in the catalog | Protocol | Zen | Go | +| :------------------ | :--------------------- | --: | ----: | +| _(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 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. -**The two bold cells were the gap, and they are closed.** The Go plane serves 11 models on shapes other than completions; both planes now have a route for every shape either one carries. What made them work was making the plane part of a route's definition, below. +**The two bold cells were the gap, and they are closed.** The Go plane serves 11 models on shapes other than completions; both planes now serve every shape either one carries, on the plane's own route (§3). + +**`@ai-sdk/mistral` is completions (D16).** pi's own data files `mistral-large-4` under `openai-completions`, so the plugin follows it rather than keeping the SDK unserved; nothing is withheld for its protocol any more. ## 3. Routes -A route is a name the host can dispatch to, carrying exactly one plane's base URL and one protocol. The plugin registers them in-process, so a profile that declares none still gets every endpoint. +A route is a name the host can dispatch to. Since P-R1 (D13) there is **one route per plane**, and it serves every protocol that plane carries: the plugin registers its own adapter — a subclass of the host's `PiAiAdapter` — for exactly the planes the profile declares, through the host's own `registerAdapter`, and `llm-pi-ai` keeps every other route. -| Route | Plane | Protocol | Base URL | -| :-------------------------- | :---- | :--------------------- | :------- | -| `opencode` | Zen | `openai-completions` | Zen | -| `opencode-go` | Go | `openai-completions` | Go | -| `opencode-responses` | Zen | `openai-responses` | Zen | -| `opencode-anthropic` | Zen | `anthropic-messages` | Zen | -| `opencode-google` | Zen | `google-generative-ai` | Zen | -| **`opencode-go-responses`** | Go | `openai-responses` | Go | -| **`opencode-go-anthropic`** | Go | `anthropic-messages` | Go | +| Route | Plane | Protocols | Base URL | +| :-- | :-- | :-- | :-- | +| `opencode` | Zen | all four, per model, from the catalog (§2) | per protocol, on the `endpoint` family | +| `opencode-go` | Go | all four, per model, from the catalog (§2) | per protocol, on the `endpoint` family | -The last two exist, and they are what closed the gap this section was written around: the Go models that name a non-completions SDK were offered with nowhere to go, and each now has a route on its own plane. The count is stated once, above, because it has already moved twice and a second copy of it is a second thing to keep true. +The per-protocol hidden routes this section used to list (`opencode-responses`, `opencode-anthropic`, `opencode-google` and their `opencode-go-*` twins) are gone, and so is the stream redirect that dispatched to them: a model's protocol and base are fields of the model object the plane adapter builds (`src/utils/plane-model.ts`), so there is nothing left to re-dispatch. ## 4. Invariants @@ -59,24 +56,27 @@ These are the rules a correct implementation satisfies. Each one has a test in | | Invariant | What it requires | | :-- | :-- | :-- | -| I1 | Every offered model is routable | If a model appears in the picker, a route exists that serves it on the shape its SDK names. A model that cannot work is not offered. This is the plugin's headline promise; the Go gap broke it once, and the Go routes closed it. | -| I2 | A route carries one plane | Its base URL, its catalog and its credential all come from the same plane. Mixing them resolves to "model not found" against the route's own endpoint, the failure `responses-provider.ts` documents and works around by passing the Zen catalog explicitly. | -| I3 | The route is chosen from (plane, SDK) | Never from the SDK alone. `@ai-sdk/mistral` shares the completions _path_ but not its protocol, and the same SDK appears on both planes. | -| I4 | No SDK means the plane's base route | A model naming no SDK stays where it is, on `openai-completions`. | -| I5 | An SDK with no protocol is not offered — unless this plugin serves it | `@ai-sdk/mistral` is the only SDK left in `UNSERVED_SDKS`, and its absence is a decision rather than a gap. `@ai-sdk/google` used to be there too; the bridge took it out by serving the protocol through the plugin's own adapter, which is why the set and the bridge's route must be read together. | +| I1 | Every offered model is routable | If a model appears in the picker, its plane's route serves it on the shape its SDK names. This is the plugin's headline promise; the Go gap broke it once, and one route per plane closed it for good. | +| I2 | A route carries one plane | Its base URLs, its catalog and its credential all come from the same plane. Mixing them resolves to "model not found" against the route's own endpoint. | +| I3 | The endpoint is chosen from (plane, SDK) | Never from the SDK alone: the same SDK appears on both planes, on each plane's own base. | +| I4 | No SDK means completions | A model naming no SDK speaks `openai-completions`, and so does an id no catalog describes (D15), unless the plane entry names an `api` for it. | +| I5 | Every SDK the catalog names maps to a protocol pi implements | `PROTOCOL_FOR_SDK` (`src/utils/protocols.ts`) covers every SDK either catalog names, and nothing is unserved; a new SDK fails the guard rather than dropping its models. | ## 5. Guards | Guard | Holds | Fails when | | :-- | :-- | :-- | -| `catalog.test.ts` · SDK coverage | I5 | the catalog names an SDK that is neither mapped nor knowingly unserved | -| `catalog.test.ts` · plane × SDK coverage | I1 | the catalog contains a (plane, SDK) pair with no route | -| `responses-routes.test.ts` · route table | I2, I3 | a route's plane or protocol is ambiguous, or the table names a base that is not its plane's | +| `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 | +| `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 | -| coverage ratchet | — | a new route is added without a test that mounts it | +| `test/mock/plane-dispatch.mock.ts` · the plane adapter | I1–I4 | a characterized model on either plane reaches a path, model or credential header other than its protocol's, through the real `LlmRuntime` | +| coverage ratchet | — | a new branch of the plane model or profile is added without a test (both are held at 100%) | + +## 6. Refactor (done, then superseded) -## 6. Refactor (done) +> Superseded by P-R1 (§3): the (plane, protocol) route table this section built is gone, because one route per plane now serves every protocol. Kept as the record of why the plane had to be a key. The gap was structural, not a missing branch: **the plane was not part of a route's definition.** `responses-provider.ts` held one base URL for every route it mounted, and `responses-routes.ts` keyed its table by protocol alone, so a Go model could not be described at all. The fix was to make the plane a key rather than a constant — `ROUTE_FOR_PLANE_PROTOCOL` now carries a base URL per plane, and the mount reads the catalog and the credential from the same one. @@ -104,8 +104,8 @@ One model, every stage. The left column is what a user can see; the right column | 3 | one model selected | the composer sets `provider` + `model`; no route, protocol or endpoint appears anywhere in the interface | the host | | 4 | a turn starts | `llm/stream` fires; the hook claims the turn only for the routes this plugin declares, and leaves every other provider alone | `stream-hook.ts` | | 5 | _(invisible)_ | the session id is derived from `ses_` + the session's real `createdAt` + a hash suffix, and the session's `cwd` becomes the project name | `session.ts` · `cordis-context.ts` | -| 6 | _(invisible)_ | **the redirect is decided**: `(plane, SDK) → protocol → route`. A model needing a shape other than its route's is re-dispatched to the route that speaks it | `responses-routes.ts` → `stream-hook.ts` | -| 7 | _(invisible)_ | the route's adapter is the host's own `llm-pi-ai`, mounted by the plugin per route, with that plane's base URL, catalog and credential | `responses-provider.ts` | +| 6 | _(invisible)_ | **the model object is built**: `(plane, SDK) → protocol → base`, with metadata from the user's row first, then the catalog, then pi (D16, D17) | `utils/plane-profile.ts` → `utils/plane-model.ts` | +| 7 | _(invisible)_ | the plane's adapter is the plugin's own subclass of the host's `PiAiAdapter`, registered for the plane route, with that plane's catalog and the credential pi-ai's own rule resolves (D20) | `host/route-claim.ts` → `host/plane-provider.ts` | | 8 | _(invisible)_ | the request is intercepted and given what the gateway expects: origin proof (the client, the project, and the turn's message id), `x-opencode-session` always, parent-session lineage, and the free-tier `read`/`bash` schemas when the body lacks them | `fetch-patch.ts` · `tool-fallback.ts` | | 9 | the reply streams in | the gateway answers; the plugin has changed nothing about the response | — | | 10 | a spend figure | the turn's usage event is priced per **plane**, so a Go turn and a Zen turn never share one number | `session-cost.ts` · `usage.ts` | @@ -116,8 +116,8 @@ Read the table as a chain of guarantees. Steps 1–3 are why the front end is on | Broken link | What the user sees | | :-- | :-- | -| Step 2 without step 6 | The row is offered on a shape nothing routes. A Go responses model sent to completions answers `500`, and the row that caused it looks exactly like a working one. This is the gap in §2. | -| Step 6 without step 7 | The redirect names a route the host has never heard of, and the turn dies with "model not found" against an endpoint the user never chose. | +| Step 2 without step 6 | The row is offered on a shape nothing serves. A Go responses model sent to completions answers `500`, and the row that caused it looks exactly like a working one. This is the gap in §2. | +| Step 6 without step 7 | The model object is right and nothing serves it: the plane stays pi-ai's, and a model pi does not carry answers "model not found" at dispatch. | | Step 7 without step 8 | A correct endpoint answers `403 FreeTierError` or `400 MissingSessionID`, which reads as an account problem rather than a missing header. | | Step 8 without step 10 | The meter prices a plan-included turn as if it were paid, because the plane never reached the accumulator. | | Step 10 without step 11 | The number is right and invisible, which is indistinguishable from not tracking it at all. | @@ -137,7 +137,7 @@ Measured 2026-10-10 by building one with pi's own protocol implementations and i `{family}` stands for the family root from §1 — `…/zen/v1` on the legacy endpoint, `…/inference/openai/v1` on the modern one — and the paths are the same on both. **The base URL is per protocol, not per plane.** The Anthropic SDK appends its own `/v1/messages`, so its base is one level shorter than the others — the family root without its `/v1`. That is not derivable from the protocol name; it is a property of each SDK, and pi's own `opencode` provider records it per model. -**The Google path is the one a hand-declared route cannot reach.** Its protocol name is not in the wrapper's three-entry table, so no route may name it; a self-built provider dispatches to it without any special case. +**The Google path is the one a hand-declared route cannot reach.** Its protocol name is not in the wrapper's three-entry table, so no route may name it; a self-built provider dispatches to it without any special case — which is what the plane adapter is (`buildPlaneProvider`, P-R1). **The session header reaches every protocol.** Wrapping each `api` before `createProvider` puts our `x-opencode-session`, `x-opencode-client` and `x-opencode-project` on all four, and pi's own session header defers when ours is already present. @@ -174,30 +174,19 @@ The narrow part is deliberate and correct: Bedrock signs with SigV4, Vertex need **A catalog route reaches every protocol, but only under the catalog's own name.** `catalogProvider(provider)` is a map lookup by route key, so a route reusing the `opencode` catalog must itself be called `opencode`. That name is already taken by whatever the deployment declared, and `registerAdapter` refuses a second registration for a provider another registration owns. So a plugin loading after the wrapper cannot claim the name: it never sees that registration's handle, and `handle.replace` — the documented way to give a route back — needs the handle it never got. -**So the unblock is upstream, and it is one line.** Adding `'google-generative-ai': googleGenerativeAIApi` to the wrapper's table is the change its own comment anticipates, and it makes the seven Google models servable through the hand-declared route mechanism this plugin already has. Until then they stay in `UNSERVED_SDKS`, which is a decision the code states rather than a model that silently disappears. - -## 12. The bridge that shipped - -Section 11 ended on "the unblock is upstream, and it is one line." Upstream cannot be waited on, and the same measurement that closed the other doors opened one more: a plugin may register its own adapter on the real `llm` service, under a name of its own choosing. `google-bridge.ts` is that adapter, and the seven Gemini models are served by it. - -What the bridge is, precisely: - -- **The adapter is the wrapper's own class.** `PiAiAdapter`, reached through the loader — so the request translation, the snapshot discipline and the retry handling are the wrapper's, not a reimplementation. What the bridge builds by hand is the profile that class consumes: the timeout and image defaults read from the wrapper's own constants, the retry policy read from the harness's own table. -- **The wire protocol is pi's own implementation**, and the one runtime pi import in this entire plugin is the lazy module that carries it. Everything else handed to the adapter is a plain object; pi reads fields and never checks identity, so the interoperate-by-structure claim is measured, not hoped. The dependency is pinned to a version the wrapper's own requirement accepts, and a test reads both live and fails the day either side moves. -- **The route is `opencode-google`, and it stays internal.** Not `google` — that name belongs to a deployment that may want the real Google AI Studio, and taking it would be the hijack this design refuses. The picker lists the models under the plane route; the stream hook dispatches the call, exactly as it does for the protocol routes. -- **The credential is the deployment's own.** The inherited `apiKeyEnv` reference, resolved through the credentials service with the launch environment as fallback, and a miss that fails loud — handing pi an `undefined` for a named reference would let it bill whatever ambient key it finds first. The bridge is zero-configuration by construction: it resolves exactly the reference the deployment declared. +**So the unblock is upstream, and it is one line.** Adding `'google-generative-ai': googleGenerativeAIApi` to the wrapper's table is the change its own comment anticipates, and it makes the seven Google models servable through the hand-declared route mechanism this plugin already has. Until then they stayed in `UNSERVED_SDKS`, a decision the code stated rather than a model that silently disappeared; §12 is how they were served without it, and the set is gone. -### What is verified, and in what composition +## 12. The bridge that shipped, and what replaced it -Recorded because "unit tested" and "runs" are different claims, and this plugin has been wrong about the second one before — the provider declared only an auth name, no test noticed, and every request would have failed with `apiKey.resolve is not a function` before a socket opened. +Section 11 ended on "the unblock is upstream, and it is one line." Upstream could not be waited on, and the same measurement that closed the other doors opened one more: a plugin may register its own adapter on the real `llm` service. `google-bridge.ts` was that adapter, under the hidden route `opencode-google`, and it served the Gemini models until P-R1. -What is now established: +P-R1 (D13) took the bridge's construction and applied it to the whole plane. The plugin's adapter is a subclass of the wrapper's own `PiAiAdapter` — so the request translation, the snapshot discipline and the retry handling are still the wrapper's — and its provider is built with pi's own `createProvider` over all four of pi's protocol implementations, each wrapped with pi's own session header. It is registered for `opencode` and `opencode-go` themselves, which §11 said a later plugin could not claim; it can, because an `internal/config` listener hands `llm-pi-ai` a copy of its configuration without the two planes, so pi-ai never registers them and their names are free. Settings still reads and writes the planes in pi-ai's own `providers` block (one read-only `settings.describe` wrapper grafts the committed entries back, D18). -- **The built bundle loads under plain Node**, resolving its one external pi import, with the bundle gate confirming that import stays external and no local path reaches the artifact. -- **The bridge runs through the REAL `PiAiAdapter` class** — the wrapper's own, reached through the loader — with pi's own Google implementation: a stream through it reaches `…/zen/v1/models/gemini-3.8-flash:streamGenerateContent?alt=sse` carrying the resolved credential on `x-goog-api-key`, and a body with `role: user` and a `thinkingConfig` whose level comes from the inherited `thinkingLevelMap`. -- **The live endpoint agrees**: a probe with a correct Gemini body answers `200` with real SSE, and the same model on the completions endpoint answers `400 ModelProtocolUnsupported`. -- **A service with the host's own registration semantics accepts the bridge** — the same duplicate check, the same handle shape — and streams through it to the URL above. +What carried over from the bridge, and why: -What is NOT established: a live DSH host loading this build. The host is restarted to pick up a plugin, and this session runs inside the host it would restart, so that check waits for a window where restarting is free. Everything short of it is measured. +- **The credential is the deployment's own**, resolved by pi-ai's exact rule (D20): a named reference through the credentials service when one is mounted, the launch environment only when none is, and a miss that fails loud with `MISSING_CREDENTIAL` before any request. +- **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`). +- **A Gemini model's thinking control comes from the catalog, not its id (D16).** pi 1.1.0 sends Google's discrete `thinkingLevel` only to ids its own pattern names (`usesGoogleThinkingLevel`: `gemini-3[.N]-pro|flash`, `gemini-flash[-lite]-latest`, `gemma-4`) and a token `thinkingBudget` to every other id, which a level model refuses with 400 INVALID_ARGUMENT ("Thinking budget is not supported for this model. Use thinking level instead.", measured live on Go `gemini-4-argon`); so a Google model whose catalog effort list is all `minimal`/`low`/`medium`/`high` (`usesCatalogThinkingLevels`) is served by level, from pi's own public exports, with the level pi itself falls back to when no effort is chosen, and every other id stays on pi's path (`src/host/google-levels.ts`). A profile that declares Off for such a model gets pi's own disable, `thinkingBudget: 0`, as pi's own level ids do; a model that cannot turn thinking off (`gemini-4-argon`) refuses it, so declaring Off there is a profile error. -The same construction would serve `@ai-sdk/mistral`. pi implements `mistral-conversations`; a bridge on this pattern is one lazy import and one row in the tables away. It is not built, because that dialect at the gateway is unmeasured — an earlier probe could not even observe the request it produces — and no entitled key exists to measure it against. The decision lives in `UNSERVED_SDKS` with a pointer here, which is the record of how it would be served the day that changes. +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` with a `thinkingLevel` and no `thinkingBudget`. diff --git a/docs/upstream-findings.md b/docs/upstream-findings.md index fa318636..6f71627d 100644 --- a/docs/upstream-findings.md +++ b/docs/upstream-findings.md @@ -389,6 +389,8 @@ Fixture ids that pointed at now-dead models were retargeted to served ones: `ste ## 3.10 The picker and the protocol-routed models — the fix was withdrawn +> **Superseded by P-R1 (D13).** There is one route per plane now, and no `internalRouteFor`: the plane adapter serves every protocol a plane carries on the plane's own route, and lists exactly the declared ids. The invariants this section describes are held by `test/mock/plane-dispatch.mock.ts` (every declared id dispatches on its protocol's path) and the declared-listing cases in `test/host-planes.test.ts` ("lists every declared id on both planes, keyless or not"); `test/picker-integration.test.ts` went with the hidden routes. The section is kept as the record of why a route's list is not ours to widen, which still holds. + `buildModelCatalog` (the composer's list) is `listProviders()` × `listModels(id)`, read from the host's own `@deepseek-ai/dsh-api-session-controller/lib/types/catalog.js`. The internal protocol routes are hidden from `listProviders`, and each carries only its own protocol's models, because `registerResponsesProvider` builds every route with `sdksForProtocol(protocol).flatMap(sdk => modelsForSdk(sdk, catalog))`. So enumerating a parent route returns that route's own models, and a Responses / Messages / Google model is only listed if the deployment declared it: @@ -405,6 +407,8 @@ The rule that replaced it: **a route's list is not ours to widen.** `listModels` ## 3.11 The provider list was wrong in both directions — FIXED +> **Superseded by P-R1 (D19).** There are no mounted routes, no Google bridge and no `SELF_MOUNTED_ROUTES` any more. `DEFAULT_PROVIDERS` is the two planes, and `providers` always includes both, whatever the row says (`src/config.ts`); `test/config.test.ts` ("always claims both planes, whatever the row lists") and `test/deleted-settings.test.ts` ("the shipped row claims exactly the two planes") hold that. The section is kept as the record of the drift. + `DEFAULT_PROVIDERS` exists to cover every route this layer mounts; an unclaimed route gets no session header, no origin headers and no key injection, silently and only on the requests that reach it. It was wrong twice: - **missing `opencode-google`**, which the Google bridge mounts rather than the wrapper (`SELF_MOUNTED_ROUTES` excludes it from the mount loop, which is why it was easy to miss). It matters because a deployment that declares a Google-protocol model gets it dispatched to this route, and an unclaimed route would send it without the gateway headers. diff --git a/scripts/verify-catalog.ts b/scripts/verify-catalog.ts index e36afdda..3e9d06fc 100644 --- a/scripts/verify-catalog.ts +++ b/scripts/verify-catalog.ts @@ -14,7 +14,8 @@ * `410 ModelDeprecated` (needs `--probe`; otherwise skipped). * 3. **The picker's join** — every servable model must reach `listModels(parentRoute)`, * which is `listProviders() × listModels(id)` and is where 55 models went missing. - * 4. **Routing** — every servable model must resolve to a route this plugin mounts. + * 4. **Routing** — every servable model must resolve, on its own plane's route, + * to a protocol and a base on that plane (`planeModelFor`, D13). * 5. **Free-tier tools** — every free model must get the core-tool fallback. * * Usage: @@ -29,22 +30,18 @@ * @module scripts/verify-catalog */ +import { OPENCODE_FAMILY_API } from "../src/catalog-data.ts"; import { getLiveGoCatalog, getLiveZenCatalog, MODELS_OPENCODE_URL, parseModelsDevCatalog, } from "../src/models-catalog.ts"; -import { - isServableModel, - mergeDiscoveredModels, -} from "../src/models-discovery.ts"; -import { - internalRouteFor, - ROUTE_FOR_PLANE_PROTOCOL, -} from "../src/responses-routes.ts"; +import { mergeDiscoveredModels } from "../src/models-discovery.ts"; import { openCodeSessionIdFor } from "../src/session.ts"; import { isCoreToolModel } from "../src/tool-fallback.ts"; +import { planeModelFor } from "../src/utils/plane-model.ts"; +import { isServableSdk, protocolForSdk } from "../src/utils/protocols.ts"; const PROBE = process.argv.includes("--probe"); const failures: string[] = []; @@ -58,17 +55,6 @@ const ok = (message: string): void => { console.log(` ok ${message}`); }; -/** The routes this plugin mounts: both plane roots plus the plane table. */ -const mountedRoutes = (): ReadonlySet => { - const routes = new Set(["opencode", "opencode-go"]); - for (const plane of Object.values(ROUTE_FOR_PLANE_PROTOCOL)) { - for (const route of Object.values(plane.routes)) { - routes.add(route); - } - } - return routes; -}; - // ── 1. Shim freshness ─────────────────────────────────────────────────────── console.log("\n1. shim freshness"); try { @@ -190,12 +176,10 @@ for (const [route, catalog, plane] of [ // earlier revision merged the whole catalog in, which silently overrode every // `models:` array. This asserts the opposite: declared in, declared out. const declared = catalog - .filter((m) => isServableModel(m.id, route)) + .filter((m) => isServableSdk(m.provider_npm)) .slice(0, 3) .map((m) => ({ id: m.id, name: m.name })); - const listed = mergeDiscoveredModels(declared, plane).filter((m) => - isServableModel(m.id, route) - ); + const listed = mergeDiscoveredModels(declared, plane); const listedIds = new Set(listed.map((m) => m.id)); const declaredIds = declared.map((m) => m.id); const added = [...listedIds].filter((id) => !declaredIds.includes(id)); @@ -213,28 +197,48 @@ for (const [route, catalog, plane] of [ // ── 4. Routing ────────────────────────────────────────────────────────────── console.log("\n4. routing"); { - const mounted = mountedRoutes(); + // One route per plane serves every protocol (D13), so a model is routable + // when the plane adapter builds it on the protocol its SDK names and on a + // base of its own plane. const unroutable: string[] = []; - for (const [route, catalog] of [ - ["opencode", getLiveZenCatalog()], - ["opencode-go", getLiveGoCatalog()], + let served = 0; + for (const [route, catalog, family] of [ + ["opencode", getLiveZenCatalog(), OPENCODE_FAMILY_API.zen], + ["opencode-go", getLiveGoCatalog(), OPENCODE_FAMILY_API.go], ] as const) { - for (const model of catalog) { - if (!isServableModel(model.id, route)) { + for (const spec of catalog) { + const { model, error } = planeModelFor({ + defaults: { + contextWindow: 262_144, + input: ["text"], + maxTokens: 32_768, + }, + endpoint: "modern", + entry: { id: spec.id }, + familyApi: family, + route, + spec, + }); + const onGo = + model !== undefined && + new URL(model.baseUrl).pathname.startsWith("/inference/go/"); + if ( + model === undefined || + model.api !== protocolForSdk(spec.provider_npm) || + onGo !== (route === "opencode-go") + ) { + unroutable.push( + `${route}/${spec.id} -> ${model === undefined ? error : `${model.api} ${model.baseUrl}`}` + ); continue; } - const target = internalRouteFor(route, model.id, model.provider_npm); - if (target !== undefined && !mounted.has(target)) { - unroutable.push(`${route}/${model.id} -> ${target}`); - } + served += 1; } } if (unroutable.length > 0) { fail(`unroutable: ${unroutable.join(", ")}`); } else { - ok( - `every servable model resolves to one of ${mounted.size} mounted routes` - ); + ok(`every catalog model (${served}) resolves on its own plane's route`); } } diff --git a/src/attachment-services.ts b/src/attachment-services.ts index 6eb11955..3a623b6e 100644 --- a/src/attachment-services.ts +++ b/src/attachment-services.ts @@ -16,7 +16,7 @@ const optionalService = (ctx: unknown, name: string) => { () => () => { if (current === service) current = undefined; }, - "google bridge: optional attachment service", + "plane adapter: optional attachment service", ]); } }, diff --git a/src/config-values.ts b/src/config-values.ts index c0093149..1e47e78d 100644 --- a/src/config-values.ts +++ b/src/config-values.ts @@ -17,32 +17,15 @@ export const ALL_MODELS_MARKER = "*"; * route we do not intercept carries no OpenCode headers and has no quota to * show. * - * **It must cover every route this layer mounts**, which is the invariant the - * list is for. `llm-pi-ai` carries one `api` per route and offers no per-model - * override, so each non-default protocol needs its own route: Responses and - * Messages for each plane, plus the Google bridge's route. An unclaimed route - * gets no session header, no origin headers, and no key injection. - * - * `opencode-google` was missing here while every other internal route was - * present. It mattered less before the picker began publishing the whole catalog - * on the parent route; now a Google model is reachable and dispatched to this - * route, so it has to be claimed like the rest. It is mounted by the Google - * bridge (`google-bridge.ts`) rather than the wrapper, which is why it is easy to - * forget: `SELF_MOUNTED_ROUTES` excludes it from the wrapper's mount loop. + * The two planes, and nothing else (D13): the plugin serves every model of a + * plane, whatever its protocol, on the plane's own route, so there is no + * second route to claim. A row may name more — a relay route of its own — and + * the resolver always adds these two back, so a row cannot opt a plane out. * * Shared here rather than in `config.ts` so the client bundle can read the same * default without importing schemastery. */ -export const DEFAULT_PROVIDERS = [ - "opencode", - "opencode-go", - "opencode-responses", - "opencode-anthropic", - "opencode-google", - "opencode-go-responses", - "opencode-go-anthropic", - "opencode-go-google", -]; +export const DEFAULT_PROVIDERS = ["opencode", "opencode-go"]; /** * Whether the meter shows session spend and the active model's rate. Shared @@ -157,22 +140,3 @@ export const readStringList = (value: unknown): string[] => { ) .map((item: string) => item.trim()); }; - -/** - * Read a positive finite integer, or `undefined` when absent/invalid. - * - * @param value - raw value, possibly a number or string. - */ -export const readPositiveInteger = (value: unknown): number | undefined => { - const raw: unknown = unwrapNode(value); - if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { - return Math.floor(raw); - } - if (typeof raw === "string" && raw.trim().length > 0) { - const parsed = Number(raw.trim()); - if (Number.isFinite(parsed) && parsed > 0) { - return Math.floor(parsed); - } - } - return undefined; -}; diff --git a/src/config.ts b/src/config.ts index fca8e03b..cdf5cb80 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,11 +24,10 @@ import { type EndpointFamily, type KeySourcePolicy, readBoolean, - readPositiveInteger, readString, readStringList, } from "./config-values.ts"; -import { isRecord } from "./utils/guards.ts"; +import { PLANE_ROUTES } from "./utils/protocols.ts"; export { DEFAULT_ENDPOINT_FAMILY, @@ -40,45 +39,6 @@ export { type KeySourcePolicy, }; -/** - * Optional user-defined overrides for a specific model in configuration. - * Allows tuning contextWindow, maxTokens, name, reasoningEfforts, etc. per model. - */ -/** - * One model's wire routing, in the shape the catalog uses. - * - * `api` is a pi protocol (`openai-completions`, `openai-responses`, - * `anthropic-messages`, `google-generative-ai`); `baseURL` is the endpoint that - * model is sent to. Both are optional, and an absent field means "whatever the - * shim and its live refresh computed". - */ -export interface ModelRouteConfig { - /** The pi protocol this model speaks. */ - readonly api?: string; - /** The endpoint this model is sent to, when it differs from the catalog's. */ - readonly baseURL?: string; -} - -export interface ModelOverrideConfig { - /** Custom context window limit in tokens. */ - readonly contextWindow?: number; - /** Custom maximum output generation tokens. */ - readonly maxTokens?: number; - /** Custom display name. */ - readonly name?: string; - /** Custom reasoning efforts to offer. */ - readonly reasoningEfforts?: readonly string[]; - /** Custom input modalities (e.g. ["text", "image"]). */ - readonly inputModalities?: readonly ("text" | "image")[]; - /** Custom token pricing override. */ - readonly cost?: { - readonly input: number; - readonly output: number; - readonly cache_read?: number; - readonly cache_write?: number; - }; -} - /** * URL substrings that mark a request as OpenCode gateway traffic even when no * turn is active. Configurable so mirrors, proxies, and self-hosted gateways @@ -135,9 +95,6 @@ export const CONFIG_DEFAULTS = { injectProject: DEFAULT_INJECT_PROJECT, injectUserAgent: true, keySource: DEFAULT_KEY_SOURCE, - maxContextWindow: undefined as number | undefined, - modelOverrides: {} as Readonly>, - modelRoutes: {} as Readonly>, originClient: DEFAULT_ORIGIN_CLIENT, providers: DEFAULT_PROVIDERS, readOpencodeStore: false, @@ -160,17 +117,6 @@ export interface PluginConfig { injectProject?: boolean; injectUserAgent?: boolean; keySource?: KeySourcePolicy; - maxContextWindow?: number; - modelOverrides?: Record; - /** - * Per-model wire routing, in the shape the catalog uses: `api` is the protocol - * and `baseURL` the endpoint for that model. - * - * This is the OVERRIDE layer over what the shim and its live refresh computed. - * A model whose protocol the catalog puts on an internal route can be pinned - * here instead. - */ - modelRoutes?: Record; originClient?: string; providers?: string[]; readOpencodeStore?: boolean; @@ -206,7 +152,6 @@ export interface ResolvedPluginConfig { endpoint: EndpointFamily; /** `*` when the core-tool fallback should run for every model. */ freeModelMarker: string; - /** Ids withheld from every listing; empty unless the deployment names some. */ /** URL substrings marking gateway traffic; never empty after resolution. */ gatewayUrls: string[]; injectCoreTools: boolean; @@ -215,13 +160,11 @@ export interface ResolvedPluginConfig { injectUserAgent: boolean; /** Which credential source wins when more than one resolves. */ keySource: KeySourcePolicy; - /** Optional ceiling to clamp model context windows. */ - maxContextWindow?: number; - /** Optional per-model configuration overrides. */ - readonly modelOverrides: Readonly>; - /** The per-model protocol/base overrides, keyed by model id. */ - readonly modelRoutes: Readonly>; originClient: string; + /** + * The route ids whose turns this plugin decorates: the two planes, always, + * and any relay route the row names beside them. + */ providers: Set; /** * Whether the OpenCode installation's own credential store may be read. @@ -268,110 +211,6 @@ const resolvedEndpoint = (raw: unknown): EndpointFamily => { return isEndpointFamily(value) ? value : CONFIG_DEFAULTS.endpoint; }; -/** - * Read the per-model routing overrides. - * - * An entry without a usable field is dropped rather than repaired: a typo must - * not silently pin a model to an endpoint nobody meant. - * - * @param raw - the config value. - * @returns the overrides, keyed by model id. - */ -const resolveModelRoutes = ( - raw: unknown -): Readonly> => { - if (!isRecord(raw)) { - return {}; - } - const result: Record = {}; - for (const [modelId, value] of Object.entries(raw)) { - if (!isRecord(value)) { - continue; - } - const api = readString(value.api); - const baseURL = readString(value.baseURL ?? value.base_url); - if (api === undefined && baseURL === undefined) { - continue; - } - result[modelId] = { - ...(api === undefined ? {} : { api }), - ...(baseURL === undefined ? {} : { baseURL }), - }; - } - return result; -}; - -const resolveModelOverrides = ( - raw: unknown -): Readonly> => { - if (!isRecord(raw)) { - return {}; - } - const result: Record = {}; - for (const [modelId, value] of Object.entries(raw)) { - if (!isRecord(value)) { - continue; - } - const contextWindow = readPositiveInteger( - value.contextWindow ?? value.context_window - ); - const maxTokens = readPositiveInteger( - value.maxTokens ?? value.max_output_tokens - ); - const name = readString(value.name); - const reasoningEfforts = readStringList( - value.reasoningEfforts ?? value.efforts - ); - let rawModalities: readonly unknown[] | undefined; - if (Array.isArray(value.inputModalities)) { - rawModalities = value.inputModalities; - } else if (Array.isArray(value.modalities)) { - rawModalities = value.modalities; - } - const inputModalities = - rawModalities === undefined - ? undefined - : rawModalities.filter( - (m: unknown): m is "text" | "image" => m === "text" || m === "image" - ); - const cost = isRecord(value.cost) - ? { - input: typeof value.cost.input === "number" ? value.cost.input : 0, - output: typeof value.cost.output === "number" ? value.cost.output : 0, - ...(typeof value.cost.cache_read === "number" - ? { cache_read: value.cost.cache_read } - : {}), - ...(typeof value.cost.cache_write === "number" - ? { cache_write: value.cost.cache_write } - : {}), - } - : undefined; - - const entry: ModelOverrideConfig = {}; - if (contextWindow !== undefined) { - Object.assign(entry, { contextWindow }); - } - if (maxTokens !== undefined) { - Object.assign(entry, { maxTokens }); - } - if (name !== undefined) { - Object.assign(entry, { name }); - } - if (reasoningEfforts.length > 0) { - Object.assign(entry, { reasoningEfforts }); - } - if (inputModalities !== undefined) { - Object.assign(entry, { inputModalities }); - } - if (cost !== undefined) { - Object.assign(entry, { cost }); - } - - result[modelId] = entry; - } - return result; -}; - export const resolveConfig = ( rawInput: PluginConfig = {} ): ResolvedPluginConfig => { @@ -410,14 +249,11 @@ export const resolveConfig = ( CONFIG_DEFAULTS.injectUserAgent ), keySource: resolvedKeySource(config.keySource), - maxContextWindow: readPositiveInteger(config.maxContextWindow), - modelOverrides: resolveModelOverrides(config.modelOverrides), - modelRoutes: resolveModelRoutes(config.modelRoutes), originClient: readString(config.originClient) ?? CONFIG_DEFAULTS.originClient, - providers: new Set( - providers.length > 0 ? providers : [...CONFIG_DEFAULTS.providers] - ), + // The planes are the plugin's own routes (D19), so a row's list adds + // relay routes to them and can never take one away. + providers: new Set([...PLANE_ROUTES, ...providers]), readOpencodeStore: readBoolean( config.readOpencodeStore, CONFIG_DEFAULTS.readOpencodeStore @@ -451,7 +287,9 @@ export const Config = z.object({ .array(z.string()) .default([...CONFIG_DEFAULTS.providers]) .volatile() - .description("Provider route keys to intercept."), + .description( + "Route ids whose turns this plugin decorates; opencode and opencode-go are always included." + ), gatewayUrls: z .array(z.string()) .default([...CONFIG_DEFAULTS.gatewayUrls]) @@ -537,7 +375,7 @@ export const Config = z.object({ .default(CONFIG_DEFAULTS.enrichModels) .volatile() .description( - "Enrich and override gateway models list using canonical models.dev catalog." + "Enrich the gateway /models listing and Settings model discovery from the OpenCode catalog; a plane's model info follows the user's row and the catalog either way." ), endpoint: z .string() @@ -554,27 +392,6 @@ export const Config = z.object({ "migration; an unrecognised value falls back to auto." ) .volatile(), - maxContextWindow: z - .number() - .volatile() - .description( - "Optional ceiling to clamp model context windows (e.g. 131072, 262144)." - ), - modelOverrides: z - .any() - .default({}) - .volatile() - .description( - "Optional per-model overrides for contextWindow, maxTokens, and metadata." - ), - modelRoutes: z - .any() - .default({}) - .volatile() - .description( - "Per-model wire routing overrides: { : { api, baseURL } }. The api " + - "names a pi protocol; baseURL replaces the catalog's endpoint." - ), usageBaseURL: z .string() .default(CONFIG_DEFAULTS.usageBaseURL) diff --git a/src/cordis-context.ts b/src/cordis-context.ts index 2029dfa4..a595994f 100644 --- a/src/cordis-context.ts +++ b/src/cordis-context.ts @@ -12,7 +12,11 @@ */ import { readString } from "./config-values.ts"; +import type { GoogleLevelSource } from "./host/google-levels.ts"; +import { defaultHostModuleSource } from "./host/host-modules.ts"; import { isFunctionLike, isRecord } from "./utils/guards.ts"; +import type { PlanePiModel } from "./utils/plane-model.ts"; +import type { PlaneRoute, Protocol } from "./utils/protocols.ts"; /** * The disposer `llm.registerAdapter` returns. @@ -82,18 +86,15 @@ export interface CordisContext { request?: unknown, signal?: AbortSignal ) => Promise; - /** - * Re-dispatch a generation. Used to hand a call to the route whose `api` - * already names the format it needs, instead of translating the protocol. - */ - stream?: (options: unknown) => unknown; /** Registered provider routes, for checking a target exists before using it. */ listProviders?: () => unknown; /** Routes offered in Settings → Models. */ listConfigurableProviders?: () => unknown; /** - * Register an adapter for routes the plugin owns. Used to serve the - * gateway's Responses plane without the user declaring anything. + * Register an adapter for routes the plugin owns. The route claim + * registers the plane adapter for exactly the planes the profile declares + * (`host/route-claim.ts`, D13), and moves them through the returned + * handle's `replace`. */ registerAdapter?: ( providers: readonly string[], @@ -101,14 +102,15 @@ export interface CordisContext { ) => AdapterRegistrationLike | undefined; /** * The models one route advertises. The browser catalog turns each route into - * a group and DROPS groups with no models, so this is how an internal route - * stays out of the picker. + * a group and DROPS groups with no models, which is why the plane adapter + * lists a plane's declared ids even when no key resolves (F18). */ listModels?: (provider: string) => Promise; /** * One exact model's capabilities. What the model picker's Effort row reads: * a route that reports no `reasoning` here shows no thinking-level control, - * which is why {@link decorateModelInfo} fills the gap for the plane routes. + * which is why the plane adapter builds every model's reasoning from the + * catalog (D1, D17). */ resolveModelInfo?: ( provider: string, @@ -116,12 +118,6 @@ export interface CordisContext { signal?: AbortSignal ) => Promise; }; - /** - * A child context whose reads and writes of `name` resolve in a new scope. - * Used to hide the authorization seam from a mounted `llm-pi-ai` instance, so - * it registers no auth flows and cannot collide with the host's own. - */ - isolate?: (name: string) => CordisContext; /** * Register a service on the current fiber. The only write a live context * proxy accepts from a running fiber: an ad-hoc property set for a name that @@ -284,8 +280,10 @@ export type LoaderService = LoaderHost["loader"]; * value it verified. * * `ctx.get` first: it reads the store WITHOUT consulting `inject`, which is what - * an optional dependency needs, and this plugin declares only `llm`. The - * property is a guarded fallback for a context that serves only that. + * an optional dependency needs, and this plugin's root declares nothing + * (`inject` is `[]`; it reaches `llm` and `settings` through `ctx.inject` + * scopes). The property is a guarded fallback for a context that serves only + * that. * * @param ctx - the plugin context, mock or real. * @returns the loader service, or `undefined`. @@ -394,3 +392,209 @@ export const readSessionMetaResolver = ( } }; }; + +// --------------------------------------------------------------------------- +// The host's own modules +// --------------------------------------------------------------------------- + +/** pi-ai's three public exports the plane adapter is built from (F19). */ +export interface PiAiModule { + readonly PiAiAdapter: new (options: Record) => object; + readonly Config: (raw: unknown) => unknown; + readonly recordKeyFor: (providerId: string) => string; +} + +/** + * The libraries pi-ai runs on, as the plane provider uses them: pi's provider + * pieces, dsh-llm's helpers and the loader's `interpolate`. Typed locally, + * because the copies at runtime are the host's, not this package's. + */ +export interface HostLibraries { + /** pi's `createProvider` (`dist/models.js`). */ + readonly createProvider: (input: Record) => unknown; + /** pi's `withOpenCodeSessionHeader` (`providers/opencode-headers.js`). */ + readonly withOpenCodeSessionHeader: (api: unknown) => unknown; + /** pi's four protocol factories (`api/*.lazy.js`). */ + readonly apis: Readonly unknown>>; + /** + * pi's pieces for a Google model the catalog describes by thinking level + * (`host/google-levels.ts`); absent when the host's pi lacks them, which + * leaves the Google protocol entirely pi's. + */ + readonly googleLevels?: GoogleLevelSource; + /** `opencodeProvider().auth`: pi's ambient OpenCode key discovery. */ + readonly opencodeAuth: unknown; + /** `opencodeGoProvider().auth`. */ + readonly opencodeGoAuth: unknown; + /** pi's installed models for a route (`getBuiltinModels`). */ + readonly builtinModels: (route: PlaneRoute) => readonly PlanePiModel[]; + /** The pi version that was loaded, for diagnostics. */ + readonly version: string; + /** The package root pi was loaded from, for diagnostics. */ + readonly root?: string; + /** dsh-llm's `resolveRetryPolicy`. */ + readonly resolveRetryPolicy: (policy: unknown, label: string) => unknown; + /** dsh-llm's `LlmError`, so a refusal carries the host's own class. */ + readonly LlmError?: new (message: string, code: string) => Error; + /** dsh-llm's `assertUsableApiKey`. */ + readonly assertUsableApiKey?: ( + raw: string, + pkg: string, + ref: string + ) => string; + /** The loader's `interpolate`, absent when no loader is installed. */ + readonly interpolate?: (ctx: unknown, value: unknown) => unknown; +} + +/** Both halves, once both resolved. */ +export interface HostModules { + readonly piAi: PiAiModule; + readonly libraries: HostLibraries; +} + +/** Where the host modules come from; the harness replaces it. */ +export interface HostModuleSource { + readonly piAi: ( + ctx: unknown, + fiber?: unknown + ) => Promise; + /** The libraries pi-ai runs on, found from the context's base URL. */ + readonly libraries: (ctx?: unknown) => Promise; +} + +let replacementSource: HostModuleSource | undefined; + +/** + * Replace where the host modules come from, until the returned restore runs. + * A bare cordis context has no loader to find pi-ai's module through, so the + * real-host harness hands the statically imported packages in here. + * + * @param source - the replacement, or `undefined` for the default. + * @returns the restore. + */ +export const replaceHostModuleSource = ( + source: HostModuleSource | undefined +): (() => void) => { + const previous = replacementSource; + replacementSource = source; + return () => { + replacementSource = previous; + }; +}; + +/** + * The host's pi-ai module and libraries, or `undefined` while either cannot be + * read. + * + * @param ctx - the plugin context. + * @param piFiber - pi-ai's fiber, whose loader entry holds its module. + */ +export const loadHostModules = async ( + ctx: unknown, + piFiber?: unknown +): Promise => { + const source = replacementSource ?? defaultHostModuleSource; + try { + const [piAi, libraries] = await Promise.all([ + source.piAi(ctx, piFiber), + source.libraries(ctx), + ]); + return piAi === undefined || libraries === undefined + ? undefined + : { libraries, piAi }; + } catch { + return undefined; + } +}; + +/** One value of a launch-environment snapshot. */ +export interface LaunchEnvironmentLike { + readonly get: (name: string) => { readonly value?: string } | undefined; +} + +/** + * The launch environment, as pi-ai reads it (`launchEnvironmentOf`, + * `launch-environment/src/index.ts:114-117`): the composition's snapshot when + * one is provided, the process environment otherwise. + * + * @param ctx - the plugin context. + */ +export const readLaunchEnvironment = (ctx: unknown): LaunchEnvironmentLike => { + let provided: unknown; + if (isRecord(ctx) && isFunctionLike(ctx.get)) { + try { + provided = Reflect.apply(ctx.get, ctx, ["launchEnvironment"]); + } catch { + provided = undefined; + } + } + if (isRecord(provided) && isFunctionLike(provided.get)) { + const { get } = provided; + return { + get: (name) => { + const hit: unknown = Reflect.apply(get, provided, [name]); + return isRecord(hit) && typeof hit.value === "string" + ? { value: hit.value } + : undefined; + }, + }; + } + return { + get: (name) => { + const value = process.env[name]; + return value === undefined ? undefined : { value }; + }, + }; +}; + +/** A cordis fiber, as far as the route claim reads one. */ +export interface HostFiber { + readonly uid: number | null; + readonly state: number; + readonly runtime?: { readonly name?: string } | null; + readonly _config?: unknown; + readonly config?: unknown; + readonly entry?: { readonly options?: { readonly id?: string } } | undefined; + readonly parent?: { readonly fiber?: { readonly state?: number } }; + readonly ctx?: unknown; + readonly await?: () => Promise; + readonly restart?: () => Promise; +} + +/** The name pi-ai's runtime is registered under. */ +export const PI_AI_RUNTIME = "llm-pi-ai"; + +/** + * Every live fiber of pi-ai, in registry order, read through `ctx.registry`'s + * runtimes. Never by `fiber.name`, which pi-ai's inject children inherit. + * + * @param ctx - the plugin context. + */ +export const piAiFibers = (ctx: unknown): HostFiber[] => { + try { + const registry: unknown = isRecord(ctx) ? ctx.registry : undefined; + if (!isRecord(registry) || !isFunctionLike(registry.values)) { + return []; + } + const runtimes: unknown = Reflect.apply(registry.values, registry, []); + const out: HostFiber[] = []; + // cordis's registry yields runtimes and each runtime's `fibers` is a + // DisposableList; both are iterated, and a non-iterable throws into the + // catch below. A fiber is read only through optional fields. + /* oxlint-disable typescript/no-unsafe-type-assertion */ + for (const runtime of runtimes as Iterable) { + if (!isRecord(runtime) || runtime.name !== PI_AI_RUNTIME) { + continue; + } + for (const fiber of (runtime.fibers ?? []) as Iterable) { + if (isRecord(fiber)) { + out.push(fiber as unknown as HostFiber); + } + } + } + /* oxlint-enable typescript/no-unsafe-type-assertion */ + return out; + } catch { + return []; + } +}; diff --git a/src/go-discovery.ts b/src/go-discovery.ts index f44f7f08..5ef8633c 100644 --- a/src/go-discovery.ts +++ b/src/go-discovery.ts @@ -533,117 +533,3 @@ export const resolveZenCreditInfo = async ( const zenKey = await resolveRef(ctx, "OPENCODE_API_KEY"); return { isConfigured: zenKey !== undefined }; }; - -/** One model entry as the operator declared it in a provider row. */ -export interface DeclaredModel { - readonly id: string; - readonly name?: string; -} - -/** - * The models a provider row DECLARES, read from the loader's configuration. - * - * **Credential-free, which is the point.** `ctx.llm.listModels(route)` resolves - * the route's credential first and answers `[]` when it cannot, so at boot — the - * ordinary case — a route that declares nineteen models reads as declaring none. - * Measured consequences, both from the same cause: - * - * - no internal route was mounted for the plane; - * - the composer's picker offered none of that route's models at all. - * - * The declaration itself needs no credential: it is the `models:` array the - * operator wrote, in the loader entry's config — the same shape - * {@link discoverGoConfig} already walks. - * - * `ctx.get` first, because `ctx.loader` throws without the name in `inject`; the - * property is the guarded fallback for a context that serves only that. - * - * @param ctx - the host context. - * @param route - the provider route id (`opencode`, `opencode-go`, ...). - * @returns the declared entries, or `undefined` when the loader holds no row for - * that route — "cannot read" and "declares nothing" must stay distinguishable. - */ -export const declaredProviderModels = ( - ctx: unknown, - route: string -): readonly DeclaredModel[] | undefined => { - if (!isRecord(ctx)) { - return undefined; - } - let loader: unknown; - const { get } = ctx as { get?: unknown }; - if (typeof get === "function") { - try { - loader = Reflect.apply(get, ctx, ["loader"]); - } catch { - loader = undefined; - } - } - if (loader === undefined) { - try { - const { loader: viaProperty } = ctx as { loader?: unknown }; - loader = viaProperty; - } catch { - return undefined; - } - } - if (!isRecord(loader) || typeof loader.entries !== "function") { - return undefined; - } - let entries: unknown; - try { - entries = Reflect.apply(loader.entries, loader, []); - } catch { - return undefined; - } - // **An ITERABLE, not an array.** The host's `loader.entries()` is a - // generator (`cordis-plugin-loader/lib/index.js`: `*entries() { for (const - // entry of Object.values(this.store)) yield entry; … }`), so an - // `Array.isArray` guard rejected it and this reader answered `undefined` for - // a loader that was working perfectly — measured as the routes still not - // mounting after the first version of this fix. - if (entries === null || typeof entries !== "object") { - return undefined; - } - if ( - typeof (entries as { [Symbol.iterator]?: unknown })[Symbol.iterator] !== - "function" - ) { - return undefined; - } - const declared: DeclaredModel[] = []; - let found = false; - for (const entry of entries as Iterable) { - if (!isRecord(entry) || !isRecord(entry.options)) { - continue; - } - const config: unknown = entry.options.config; - if (!isRecord(config) || !isRecord(config.providers)) { - continue; - } - const provider: unknown = config.providers[route]; - if (!isRecord(provider)) { - continue; - } - found = true; - const models: unknown = provider.models; - if (!Array.isArray(models)) { - continue; - } - for (const model of models) { - if (!isRecord(model) || typeof model.id !== "string") { - continue; - } - if (model.id.length === 0) { - continue; - } - declared.push({ - id: model.id, - ...(typeof model.name === "string" && model.name.length > 0 - ? { name: model.name } - : {}), - }); - } - } - return found ? declared : undefined; -}; diff --git a/src/google-bridge.ts b/src/google-bridge.ts deleted file mode 100644 index 99f52541..00000000 --- a/src/google-bridge.ts +++ /dev/null @@ -1,614 +0,0 @@ -/** - * The bridge: a route this plugin registers through its own adapter, for a - * protocol the wrapper's hand-declared table refuses to name. - * - * Why this module exists is a chain of closed doors, each one measured: - * - * - A hand-declared route may only name a protocol in the wrapper's table, and - * that table does not carry `google-generative-ai`. Its own comment calls the - * omission "for want of a consumer rather than a blocker" — one line, once a - * deployment needs it — but this plugin cannot add that line: the table is a - * module-private constant, unreachable from outside. - * - A catalog route reaches every protocol, but only under the catalog - * provider's own name, and `opencode` is taken by whatever the deployment - * declared. Reusing pi's `google` catalog provider would work — measured, - * end to end — but registering the name `google` would take it away from a - * deployment that wants the real Google AI Studio under it. That trade is - * not ours to make, which is why this route is `opencode-google` and stays - * internal: the picker lists its models under the plane route, and the - * stream hook dispatches the call here. - * - Handing the route back (`handle.replace`) needs the registration handle of - * whoever owns `opencode`, and a plugin that loads after the wrapper never - * sees that handle. - * - * So the route is served by an adapter this plugin builds and registers on the - * real `llm` service — but the adapter is the wrapper's own class - * (`PiAiAdapter`, reached through the loader), and the wire protocol is pi's - * own implementation, so nothing about the request shape is reinvented here. - * - * The one runtime pi import in this entire plugin is resolved HERE, at runtime, - * and deliberately not declared as a dependency. - * - * **Why not a dependency.** The harness wrapper (`dsh-llm-pi-ai`) already - * depends on `@earendil-works/pi-ai` with a RANGE (`^1.1.0`). The harness's - * own lockfile pins that to 1.1.0 plus its patch; a registry install of the - * published host resolves the range to the newest 1.x; this repo's dev install - * pins 1.1.0 (`test/support/host-versions.ts`). Declaring the - * same package as an exact dependency here puts a SECOND pi in the tree and - * turns every harness bump into a version-skew bug: the worst measured shape is - * a degraded Google route whose error names the model rather than the version. - * - * So the rule is the user's own: *use whatever the harness provides*. The - * lookup below asks the harness which pi that is, from the wrapper's own - * location (its `package.json` declares the range; node's lookup from there - * finds the installed copy), so the bridge always wires the same implementation - * the wrapper mounts — one instance, one version, no pin to chase. - * - * The plugin's own copy stays in `devDependencies`, for the unit tests and for - * this repo's own development, and is never what a deployment loads. - * - * pi's `exports` map declares `types` and `import` only — no `require` — so this - * resolves by walking node's own pnpm/node_modules layout rather than with - * `createRequire`, which would only ever see the `require` condition and miss. - * - * The same construction would serve `@ai-sdk/mistral`: pi implements - * `mistral-conversations`, and a bridge on this pattern is one lazy import - * away. It is not built, because that dialect at the gateway is unmeasured and - * no entitled key exists to measure it — the decision lives in - * `UNSERVED_SDKS`, with this module as the record of how it would be served. - * - * @module dsh-opencode-patch/google-bridge - */ - -import { existsSync, readFileSync, realpathSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -import { attachmentResolvers } from "./attachment-services.ts"; -import type { CatalogModelSpec } from "./catalog-data.ts"; -import type { EndpointFamily } from "./config-values.ts"; -import { readCredentialsResolver } from "./cordis-context.ts"; -import { resolveOpencodeStoreKey } from "./go-discovery.ts"; -import { type Plane, piModelFor } from "./pi-provider.ts"; -import { - GO_GOOGLE_INTERNAL_ROUTE, - GOOGLE_INTERNAL_ROUTE, -} from "./responses-routes.ts"; -import { isFunctionLike, isRecord } from "./utils/guards.ts"; - -/** - * pi's installed catalog entry for one model on one plane, or undefined when - * pi does not know it. - * - * The bridge builds its models by hand rather than reusing a catalog route, so - * this is where the entry the installed provider already carries comes back: - * the compatibility switches, the `reasoning` flag, the thinking-level map and - * the input limits are all things pi knows and this module does not, and a model - * built without them would serve requests with those behaviours silently off. - * Spreading that entry is what the wrapper's own materialization does. - * - * @param plane - the gateway plane, which is also pi's provider id. - * @param id - the model id. - * @returns the installed entry, or undefined when the catalog lacks it. - */ -/** - * The slice of pi's Google provider the bridge calls: two stream entry points. - * - * Typed here rather than imported from pi's own declarations, because the - * package this repo compiles against is a devDependency at one version while - * the runtime copy is whatever the harness installed — the bridge only needs - * these two methods, and stating them locally is what keeps a harness bump a - * runtime decision instead of a type error. - */ -export interface PiGoogleApi { - stream: (model: never, context: never, options?: never) => unknown; - streamSimple: (model: never, context: never, options?: never) => unknown; -} - -/** The wrapper whose own dependency tree names the pi the host installed. */ -const WRAPPER_PACKAGE = "@deepseek-ai/dsh-llm-pi-ai"; - -/** The pi surface the bridge needs, read off whichever copy the host installed. */ -export interface PiRuntime { - /** pi's Google wire implementation, as `googleProviderFor` consumes it. */ - googleGenerativeAIApi: () => PiGoogleApi; - /** pi's own installed catalog, per provider id. */ - getBuiltinModels: (plane: string) => readonly { id: string }[]; - /** The version string of the copy that was loaded, for diagnostics. */ - version: string; -} - -/** The two module paths the bridge needs, relative to a pi package root. */ -const PI_GOOGLE_API = "dist/api/google-generative-ai.lazy.js"; -const PI_PROVIDERS_ALL = "dist/providers/all.js"; - -/** - * Where the installed pi lives, found by walking node's lookup from the - * WRAPPER's own directory. - * - * The wrapper declares pi as a dependency, so the copy node would hand IT is - * the copy every other consumer in that tree gets — which is the whole point. - * The walk is explicit because pnpm's layout is not a plain `node_modules` - * beside the package: the wrapper's dependencies live in the `.pnpm` hosting - * directory above it (or in a hoisted store beside it), and under a `link:` or - * `file:` install they live somewhere else again. Walking the parent chain and - * testing for the package is the one rule that covers all of them without - * encoding a package-manager layout. - * - * @returns the pi package ROOT, or `undefined` when the wrapper cannot be - * located or carries no pi. - */ -const installedPiRoot = (): string | undefined => { - // `import.meta.resolve` honours the wrapper's `import`/`default` conditions, - // which is what an ESM-only package declares. It is resolved from THIS - // module's own location, so the answer is the wrapper this plugin's tree - // installed — the one the host loader mounts. - let wrapperEntry: string | undefined; - try { - wrapperEntry = import.meta.resolve(WRAPPER_PACKAGE); - } catch { - wrapperEntry = undefined; - } - if (wrapperEntry === undefined) { - return undefined; - } - let dir = path.dirname(realpathSync(fileURLToPath(wrapperEntry))); - // The entry is `/lib/index.js`; its `node_modules` walk starts at the - // package root, which the hosting directory above it also covers, so walking - // up from the entry's own directory reaches the same install either way. - for (let depth = 0; depth < 8; depth += 1) { - const candidate = path.join( - dir, - "node_modules", - "@earendil-works", - "pi-ai" - ); - if (existsSync(path.join(candidate, "package.json"))) { - return candidate; - } - const parent = path.dirname(dir); - if (parent === dir) { - break; - } - dir = parent; - } - return undefined; -}; - -/** - * The pi the HOST installed, loaded once. - * - * Everything the bridge needs from pi comes through this record, so a harness - * that bumps pi changes the wire implementation the route speaks and nothing - * else about the bridge: the adapter, the profile and the models are this - * plugin's own objects, and pi reads fields off them rather than checking - * identity. - * - * @returns the runtime, or `undefined` when the host's pi is not reachable — - * in which case the bridge simply does not mount, and says so on the console. - */ -export const loadHostPi = async (): Promise => { - const root = installedPiRoot(); - if (root === undefined) { - return undefined; - } - try { - const manifest = readFileSync(path.join(root, "package.json"), "utf-8"); - const [googleApi, providersAll] = await Promise.all([ - import(pathToFileURL(path.join(root, PI_GOOGLE_API)).href), - import(pathToFileURL(path.join(root, PI_PROVIDERS_ALL)).href), - ]); - // Read the two exports off the dynamically imported modules, and narrow - // before using either: a pi that has moved an export answers `undefined` - // here rather than throwing somewhere inside a request. - const googleApiModule: unknown = googleApi; - const providersModule: unknown = providersAll; - if (!isRecord(googleApiModule) || !isRecord(providersModule)) { - return undefined; - } - const { googleGenerativeAIApi } = googleApiModule; - const { getBuiltinModels } = providersModule; - if ( - typeof googleGenerativeAIApi !== "function" || - typeof getBuiltinModels !== "function" - ) { - return undefined; - } - const manifestData: unknown = JSON.parse(manifest); - const version = - isRecord(manifestData) && typeof manifestData.version === "string" - ? manifestData.version - : ""; - return { - // pi's own signatures, as this module consumes them. Narrowed at the - // boundary rather than asserted from `any`: the export check above is the - // runtime guard these casts stand in for. - getBuiltinModels: getBuiltinModels as PiRuntime["getBuiltinModels"], - googleGenerativeAIApi: () => - (googleGenerativeAIApi as () => unknown)() as PiGoogleApi, - version, - }; - } catch { - return undefined; - } -}; - -/** Go's Google protocol has no endpoint on the legacy Zen API. */ -export const GO_GOOGLE_BASE_URL = - "https://opencode.ai/inference/go/google/v1beta"; - -const googleRouteFor = (plane: Plane): string => - plane === "opencode-go" ? GO_GOOGLE_INTERNAL_ROUTE : GOOGLE_INTERNAL_ROUTE; - -/** The host adapter's default: five minutes without a chunk. */ -const STREAM_IDLE_TIMEOUT_MS = 300_000; - -/** - * The request-level image budget a route defaults to, read from the wrapper's - * own constants: twenty MiB of base64 payload, 2048 by 2048 pixels, and one - * MiB as the per-image byte target after the quality ladder. - */ -const MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024; -const REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048; -const REQUEST_IMAGE_MAX_BYTES = 1024 * 1024; - -/** - * The retry policy a route carries when its deployment configured none, read - * from the harness's own defaults: five retries over `EMPTY_RESPONSE`, - * `RATE_LIMIT`, `SERVER`, `TIMEOUT` and `TRANSPORT`, backing off from half a - * second to ten with a tenth of jitter. - */ -const gatewayRetryPolicy = (): unknown => - Object.freeze({ - mode: "normal", - maxRetries: 5, - retryableCodes: [ - "EMPTY_RESPONSE", - "RATE_LIMIT", - "SERVER", - "TIMEOUT", - "TRANSPORT", - ], - initialDelayMs: 500, - maxDelayMs: 10_000, - jitterRatio: 0.1, - }); - -/** - * A credential store with nothing in it. This route's key always arrives per - * request through `resolveApiKey`, so the store pi would consult for - * login-written credentials is never read for a stream call; an empty - * implementation is the honest shape for that, not a stub hiding a miss. - */ -/** The credential-store surface pi's collection calls. */ -export interface BridgeCredentialStore { - delete: (providerId: string, options?: unknown) => Promise; - list: (options?: unknown) => Promise; - modify: ( - providerId: string, - fn: (current?: unknown) => Promise, - options?: unknown - ) => Promise; - read: (providerId: string, options?: unknown) => Promise; -} - -export const emptyCredentialStore = (): BridgeCredentialStore => ({ - // Nothing was ever stored, so every read is absent and nothing is ever - // withdrawn: the route's key always arrives per request instead. - delete: (): Promise => Promise.resolve(), - list: (): Promise => Promise.resolve([]), - modify: ( - _providerId: string, - fn: (current?: unknown) => Promise - ): Promise => fn(), - read: (): Promise => Promise.resolve(), -}); - -/** - * Ambient auth lookups over the process environment. The routes this plugin - * serves name a credential reference, and a named reference that misses fails - * loud rather than falling through to pi's own discovery, so these lookups - * exist to satisfy the collection's shape — the plugin's own resolution always - * answers first. - */ -/** The ambient-lookup surface a provider asks while resolving its own auth. */ -export interface BridgeAuthContext { - env: (name: string) => Promise; - fileExists: (path: string) => Promise; -} - -export const ambientAuthContext = (): BridgeAuthContext => ({ - env: (name: string): Promise => - Promise.resolve(process.env[name]), - fileExists: (): Promise => Promise.resolve(false), -}); - -/** - * pi's installed catalog entry for one model on one plane, or undefined when - * pi does not know it. - * - * The bridge builds its models by hand rather than reusing a catalog route, so - * this is where the entry the installed provider already carries comes back: - * the compatibility switches, the `reasoning` flag, the thinking-level map and - * the input limits are all things pi knows and this module does not, and a model - * built without them would serve requests with those behaviours silently off. - * Spreading that entry is what the wrapper's own materialization does. - * - * @param pi - the host's pi runtime. - * @param plane - the gateway plane, which is also pi's provider id. - * @param id - the model id. - * @returns the installed entry, or undefined when the catalog lacks it. - */ -const installedEntryFor = ( - pi: PiRuntime, - plane: Plane, - id: string -): object | undefined => - // The plane's whole installed catalog, searched for the id, rather than a - // lookup by literal-typed key: the ids looked up here come from our own - // catalog, which is ahead of the installed one, and half of them are not in - // it at all. A miss is simply no entry to inherit. - pi.getBuiltinModels(plane).find((model) => model.id === id); - -/** - * The provider this route serves: pi's Google wire implementation carrying our - * catalog's Gemini models. - * - * Built by hand, the same way the wrapper's own `reuseCatalogProvider` builds - * one — a provider is a record pi dispatches through, not a class it - * instantiates. `getModels` returns the models of the Zen plane whose SDK is - * `@ai-sdk/google`, each carrying the base URL this protocol uses on that - * plane (`…/inference/google/v1beta` under auto or modern, and - * `…/inference/go/google/v1beta` on Go), so the request lands on - * `…/inference/google/v1beta/models/:streamGenerateContent`. - * - * The streams are pi's raw implementation. The session, client, project and - * User-Agent headers are NOT added here: the fetch patch already restores all - * of them for every gateway-matched request, measured to override rather than - * append, so the bridge would only duplicate that work. - * - * @param specs - the catalogued models the route serves. - * @param family - the endpoint family to send them to; auto (an alias of - * modern) by default. - * @returns the provider the adapter's collection will hold. - */ -export const googleProviderFor = ( - pi: PiRuntime, - specs: readonly CatalogModelSpec[], - family: EndpointFamily = "auto", - plane: Plane = "opencode" -): Record => { - const route = googleRouteFor(plane); - const api = pi.googleGenerativeAIApi(); - const models = specs.map((spec) => ({ - ...piModelFor( - plane, - spec, - "google-generative-ai", - installedEntryFor(pi, plane, spec.id), - family - ), - ...(plane === "opencode-go" ? { baseUrl: GO_GOOGLE_BASE_URL } : {}), - provider: route, - })); - return { - auth: { - apiKey: { - name: "OpenCode API key", - /** - * Hand the key pi's auth resolver asks for back to the protocol. - * - * pi never reads the credential itself: it resolves the provider's - * `auth.apiKey.resolve`, puts the `auth` this returns into the stream - * options, and the Google implementation reads `options.apiKey` from - * there. So a provider that declares only a name never authenticates — - * every request fails with "API key auth failed for provider …: apiKey. - * resolve is not a function" before a socket is opened, which is - * exactly what the stream-path test caught. - * - * It returns the value rather than a promise for it: pi awaits the - * result either way, and there is nothing here to wait for. - * - * The key arrives in the credential, because the adapter resolved it - * through this plugin's own `resolveApiKey` — the credentials service - * first, the launch environment second — and passed it as the request's - * `apiKey` override. Returning that is the whole job. The empty case - * throws rather than returning nothing: a silent `undefined` would send - * an unauthenticated request and answer with a gateway error that says - * the key is missing, rather than saying this plugin could not find one. - */ - resolve: (input: { - credential?: { key?: string }; - }): { auth: { apiKey: string }; source: string } => { - const key = input.credential?.key; - if (key === undefined || key.length === 0) { - throw new Error( - `${route} reached pi's auth resolver without a key` - ); - } - return { auth: { apiKey: key }, source: "the plugin's resolved key" }; - }, - }, - }, - getModels: () => models, - id: route, - name: - plane === "opencode-go" ? "OpenCode Go (Google)" : "OpenCode (Google)", - stream: (model: never, context: never, options?: never) => - api.stream(model, context, options), - streamSimple: (model: never, context: never, options?: never) => - api.streamSimple(model, context, options), - }; -}; - -/** - * Resolve one credential reference the way every route this plugin serves - * does: through the credentials service, with the launch environment as - * fallback, and a miss that fails loud. - * - * A miss must not return `undefined` for a named reference: pi treats a - * request-level `undefined` as "resolve ambient auth instead", which would - * pick up an unrelated key — `OPENAI_API_KEY` and friends — and bill another - * tenant for a request this route meant to authenticate differently. - * - * @param ctx - host context carrying the credentials service. - * @param ref - the credential reference, when the profile names one. - * @param route - the route asking, for the failure's message. - * @returns the resolved key, or `undefined` only when no reference was named. - * @throws {Error} when a named reference resolves to nothing. - */ -export const resolveGatewayKey = async ( - ctx: unknown, - ref: unknown, - route: string -): Promise => { - if (typeof ref !== "string" || ref.length === 0) { - return undefined; - } - const resolver = readCredentialsResolver(ctx); - const viaService = await resolver?.(ref); - const hit = viaService?.value ?? process.env[ref]; - if (typeof hit === "string" && hit.length > 0) { - return hit; - } - throw new Error( - `[dsh-opencode-patch] no credential for provider route "${route}";` + - ` it resolves ${ref}, which is not set — store ${ref} through the` + - " credentials service or export it, and remove the reference only if" + - " this route should authenticate some other way (MISSING_CREDENTIAL)" - ); -}; - -/** What {@link registerGoogleBridge} needs from its caller. */ -export interface GoogleBridgeInputs { - /** The billing plane whose catalog, endpoint and credential this route uses. */ - readonly plane?: Plane; - /** Host context, for resolving the credential reference. */ - readonly ctx: unknown; - /** The real `llm` service — the bridge registers on it directly. */ - readonly llm: { - registerAdapter: (routes: readonly string[], adapter: unknown) => unknown; - }; - /** The loaded `llm-pi-ai` module, whose `PiAiAdapter` the bridge reuses. */ - readonly host: Record | undefined; - /** - * The pi the HOST installed, as {@link loadHostPi} found it. - * - * Required rather than looked up here so the caller decides what to do when - * the host's pi is missing: `registerResponsesProvider` loads it once and - * skips the mount with a console line, while a test passes its own copy. - */ - readonly pi: PiRuntime; - /** The catalogued models the route serves. */ - readonly specs: readonly CatalogModelSpec[]; - /** The credential reference inherited from the deployment's own route. */ - readonly apiKeyRef: string; - /** - * Whether the installation's own credential store may be read. - * - * One switch for both halves of it, because it is one decision: may this - * plugin read another program's store at all. It governs the stored LOGIN - * (the alternative to the device flow, reached right after the grant) and the - * stored KEY (the last resort of the same chain), and it is off unless the - * deployment opted in, which is the default. - */ - readonly readOpencodeStore?: boolean; - /** - * Which endpoint family the bridge's models are sent to. Auto (an alias of - * modern) by default, and the same choice the internal routes use so a - * deployment cannot end up on two families at once. - */ - readonly endpoint?: EndpointFamily; -} - -/** - * Register the Google bridge route on the real `llm` service. - * - * Nothing about this depends on the wrapper's mount: the adapter is the - * wrapper's own class, but it comes from the loaded module, so a deployment - * that declares every protocol route in its profile still gets the Gemini - * models — they were the one protocol a declared route could never name. - * - * The resolution is the plugin's default: the inherited - * reference, resolved through the credentials service with the launch - * environment as fallback. A miss fails loud, because handing pi `undefined` - * for a named reference would let it pick up an unrelated ambient key and - * bill another tenant. - * - * @param inputs - the context, the service, the loaded wrapper, the models - * and the credential reference. - * @returns the registration handle for the caller to withdraw, or `undefined` - * when the bridge cannot mount here. - */ -export const registerGoogleBridge = (inputs: GoogleBridgeInputs): unknown => { - const { ctx, host, llm, specs } = inputs; - const plane = inputs.plane ?? "opencode"; - const routeId = googleRouteFor(plane); - const adapter = host?.PiAiAdapter; - if (typeof adapter !== "function" || specs.length === 0) { - return undefined; - } - const provider = googleProviderFor(inputs.pi, specs, inputs.endpoint, plane); - - // One profile, built once: the adapter memoizes its snapshot by the identity - // of the profiles map, so a fresh map per call would rebuild the collection - // on every request for no reason. - const profile: Record = { - apiKeyEnv: inputs.apiKeyRef, - configuredMaxTokens: new Map(), - displayName: provider.name, - maxRequestImageBytes: MAX_REQUEST_IMAGE_BYTES, - modelErrors: new Map(), - piProvider: provider, - provider: routeId, - requestImageMaxBytes: REQUEST_IMAGE_MAX_BYTES, - requestImagePixelBudget: REQUEST_IMAGE_PIXEL_BUDGET, - retryPolicy: gatewayRetryPolicy(), - streamIdleTimeoutMs: STREAM_IDLE_TIMEOUT_MS, - }; - const profiles = new Map([[routeId, profile]]); - - const resolveApiKey = ( - route: unknown, - resolved: { apiKeyEnv?: unknown } - ): Promise => { - // A route that named a reference is answered by it, loudly if it resolves - // nothing: `undefined` would tell the adapter to find ambient auth instead. - const named = resolved.apiKeyEnv; - if (typeof named === "string" && named.length > 0) { - return resolveGatewayKey(ctx, named, String(route)); - } - // With no named reference, optionally read the owning plane's stored API key. - return Promise.resolve( - resolveOpencodeStoreKey(plane, inputs.readOpencodeStore === true) - ); - }; - - const images = attachmentResolvers(ctx); - try { - const built: unknown = Reflect.construct(adapter, [ - { - auth: { - authContext: ambientAuthContext(), - credentials: emptyCredentialStore(), - }, - profiles: () => profiles, - resolveApiKey, - resolveAttachments: images.resolveAttachments, - resolveImageAccess: images.resolveImageAccess, - }, - ]); - const registration = llm.registerAdapter([routeId], built); - return () => { - try { - if (isFunctionLike(registration)) - Reflect.apply(registration, undefined, []); - } finally { - images.dispose(); - } - }; - } catch (error) { - images.dispose(); - throw error; - } -}; diff --git a/src/host/describe-graft.ts b/src/host/describe-graft.ts new file mode 100644 index 00000000..e36eff11 --- /dev/null +++ b/src/host/describe-graft.ts @@ -0,0 +1,110 @@ +/** + * Settings keeps showing the planes (D18, F20). + * + * `settings.describe()` builds pi-ai's displayed `value` from pi-ai's RESOLVED + * config, and the route claim keeps the two planes out of that, so on its own + * the Settings page would read both planes as unconfigured — and + * session-controller's `hasProviderApiKey`, which reads the same value, would + * replace the user's default model after a Sign-in. One read-only wrapper puts + * the committed plane entries back into pi-ai's descriptor. `base`, `user` + * and the revision are left exactly as the host built them, and the user's + * file is never written: the Models page edits it through `settings.mutate`. + * + * The gateway and `settings.write()` resolve `describe` per call, so an + * instance wrapper is what every reader runs. + * + * @module dsh-opencode-patch/host/describe-graft + */ + +import type { CordisContext } from "../cordis-context.ts"; +import { isFunctionLike, isRecord } from "../utils/guards.ts"; +import { PLANE_ROUTES, type PlaneRoute } from "../utils/protocols.ts"; +import { patchMethod } from "./patch-method.ts"; +import type { RouteClaim } from "./route-claim.ts"; + +/** + * The descriptors with the missing plane entries grafted into one namespace's + * `value.providers`. Pure: the input is never mutated, and every other + * descriptor is handed back as it was. + * + * @param descriptors - what `settings.describe()` answered. + * @param ns - pi-ai's settings namespace. + * @param entries - the committed plane entries. + * @returns the descriptors, the one changed by copy. + */ +export const graftPlaneEntries = ( + descriptors: unknown, + ns: string, + entries: Readonly>> +): unknown => { + if (!Array.isArray(descriptors)) { + return descriptors; + } + return descriptors.map((descriptor: unknown) => { + if ( + !isRecord(descriptor) || + descriptor.ns !== ns || + !isRecord(descriptor.value) + ) { + return descriptor; + } + const providers = isRecord(descriptor.value.providers) + ? descriptor.value.providers + : {}; + const missing = PLANE_ROUTES.filter( + (plane) => + Object.hasOwn(entries, plane) && !Object.hasOwn(providers, plane) + ); + if (missing.length === 0) { + return descriptor; + } + return { + ...descriptor, + value: { + ...descriptor.value, + providers: { + ...providers, + ...Object.fromEntries( + missing.map((plane) => [plane, entries[plane]]) + ), + }, + }, + }; + }); +}; + +/** + * Wrap `settings.describe` for as long as the settings service is mounted. + * + * @param ctx - the plugin context. + * @param claim - the route claim whose committed entries are grafted. + */ +export const installDescribeGraft = ( + ctx: CordisContext, + claim: Pick +): void => { + ctx.inject?.(["settings"], (scope: unknown) => { + if (!isRecord(scope) || !isRecord(scope.settings)) { + return; + } + const stop = patchMethod( + scope.settings, + "describe", + (unpatched) => + function describe(this: unknown, ...args: unknown[]): unknown { + return graftPlaneEntries( + Reflect.apply(unpatched, this, args), + claim.piAiNamespace(), + claim.committedEntries() + ); + }, + { id: "dsh-opencode-patch/settings-describe" } + ); + if (isFunctionLike(scope.effect)) { + Reflect.apply(scope.effect, scope, [ + () => () => stop?.(), + "dsh-opencode-patch.describe-graft", + ]); + } + }); +}; diff --git a/src/host/google-levels.ts b/src/host/google-levels.ts new file mode 100644 index 00000000..7b5ee67f --- /dev/null +++ b/src/host/google-levels.ts @@ -0,0 +1,215 @@ +/** + * The plane's Google protocol, for a model the catalog describes by thinking + * LEVEL that pi's own pattern does not (D16). + * + * Google has two thinking controls, and a model accepts only its own: a + * discrete `thinkingLevel`, or a token `thinkingBudget`. pi 1.1.0 picks the + * level control only for ids its pattern names (`usesGoogleThinkingLevel`: + * `gemini-3[.N]-pro|flash`, `gemini-flash[-lite]-latest`, `gemma-4`) and sends + * every other id a budget — on the enabled path (`thinkingBudget: -1`) and on + * the disabled one (`thinkingBudget: 0`). A level model outside the pattern + * refuses both with 400 INVALID_ARGUMENT ("Thinking budget is not supported + * for this model. Use thinking level instead."), measured live on Go + * `gemini-4-argon`. + * + * So `streamSimple` is wrapped for the ids the catalog describes by level + * (`usesCatalogThinkingLevels` in `utils/model-metadata.ts`), and each such call is built the way pi + * builds one for a level id, from pi's own PUBLIC exports + * (`api/simple-options`, `models`, `api/google-shared`, + * `api/google-generative-ai`), loaded from the host's pi: + * + * - a chosen level: `thinking: { enabled: true, level }`, clamped and mapped + * as pi clamps and maps it; + * - no level, or Off: pi's disabled config for a level model — the level pi + * falls back to (`clampThinkingLevel(model, "off")`), sent as + * `thinkingConfig: { thinkingLevel }` through the payload hook, since pi's + * request builder decides the disabled config by the same pattern. Only a + * model that offers Off is disabled outright, as pi disables one — which + * for an id pi's pattern misses is `thinkingBudget: 0`, exactly what pi + * sends its own level ids. The catalog never offers Off on Google + * (`reasoningFor` pins `off: null`); only a profile that declares one does, + * and a model that cannot turn thinking off (`gemini-4-argon`) refuses it, + * so declaring Off there is a profile error. + * + * Every other id, every other entry point and every other protocol is pi's, + * with the same arguments; an id pi's pattern already serves by level is + * handed back to pi too, so the wrapper retires itself when pi learns the id. + * + * @module dsh-opencode-patch/host/google-levels + */ + +import { isFunctionLike, isRecord } from "../utils/guards.ts"; + +/** A pi model, as far as this module reads one; the rest is pi's. */ +type PiModel = Readonly>; + +/** pi's stream options, as far as this module reads them. */ +type StreamOptions = Readonly>; + +/** pi's public pieces a level request is built from. */ +export interface GoogleLevelPieces { + /** `api/google-generative-ai` `stream`. */ + readonly stream: ( + model: PiModel, + context: unknown, + options: StreamOptions + ) => unknown; + /** `api/simple-options` `buildBaseOptions`. */ + readonly buildBaseOptions: ( + model: PiModel, + context: unknown, + options: StreamOptions | undefined, + apiKey: unknown + ) => Readonly>; + /** `models` `clampThinkingLevel`. */ + readonly clampThinkingLevel: (model: PiModel, level: string) => string; + /** `api/google-shared` `usesGoogleThinkingLevel`: pi's id pattern. */ + readonly usesGoogleThinkingLevel: (model: PiModel) => boolean; + /** `api/google-shared` `resolveGoogleThinkingLevel`. */ + readonly resolveGoogleThinkingLevel: ( + model: PiModel, + level: string + ) => string; + /** `api/google-shared` `toGoogleThinkingLevel`. */ + readonly toGoogleThinkingLevel: (level: string) => string; + /** `api/google-shared` `toGoogleSdkThinkingLevel`. */ + readonly toGoogleSdkThinkingLevel: (level: string) => unknown; +} + +/** + * Where the pieces come from: pi's `lazyStream` (`api/lazy`), which answers a + * stream synchronously and ends it with an error event when the setup throws, + * and a loader for the pieces, which pull in the Google SDK and so load only + * on a level model's first call, as pi's own lazy Google API does. + */ +export interface GoogleLevelSource { + readonly lazyStream: ( + model: PiModel, + setup: () => Promise + ) => unknown; + readonly load: () => Promise; +} + +/** The value pi's builder accepts as a Google thinking level, for one pi level. */ +const sdkLevel = ( + pieces: GoogleLevelPieces, + model: PiModel, + level: string +): unknown => + pieces.toGoogleSdkThinkingLevel( + pieces.toGoogleThinkingLevel( + pieces.resolveGoogleThinkingLevel(model, level) + ) + ); + +/** + * pi's `streamSimple` for the Google protocol, as pi runs it for an id its + * pattern names (`google-generative-ai.js` `streamSimple`, and + * `google-shared.js` `getDisabledGoogleThinkingConfig` for the disabled path). + */ +const levelStreamSimple = ( + pieces: GoogleLevelPieces, + model: PiModel, + context: unknown, + options: StreamOptions | undefined +): unknown => { + const apiKey = options?.apiKey; + if (typeof apiKey !== "string" || apiKey.length === 0) { + throw new Error(`No API key for provider: ${String(model.provider)}`); + } + const base = { + ...pieces.buildBaseOptions(model, context, options, apiKey), + toolChoice: options?.toolChoice, + }; + const chosen = + typeof options?.reasoning === "string" && options.reasoning.length > 0 + ? pieces.clampThinkingLevel(model, options.reasoning) + : "off"; + if (chosen !== "off") { + return pieces.stream(model, context, { + ...base, + thinking: { + enabled: true, + level: pieces.toGoogleThinkingLevel( + pieces.resolveGoogleThinkingLevel(model, chosen) + ), + }, + }); + } + const fallback = pieces.clampThinkingLevel(model, "off"); + if (fallback === "off") { + return pieces.stream(model, context, { + ...base, + thinking: { enabled: false }, + }); + } + const thinkingConfig = { thinkingLevel: sdkLevel(pieces, model, fallback) }; + const caller = options?.onPayload; + return pieces.stream(model, context, { + ...base, + onPayload: async (params: unknown, payloadModel: unknown) => { + const next = + isRecord(params) && isRecord(params.config) + ? { ...params, config: { ...params.config, thinkingConfig } } + : params; + const answered: unknown = isFunctionLike(caller) + ? await Reflect.apply(caller, undefined, [next, payloadModel]) + : undefined; + return answered === undefined ? next : answered; + }, + thinking: { enabled: false }, + }); +}; + +/** + * pi's Google API with `streamSimple` serving the catalog's level models by + * level. Answers the api itself when there is nothing to wrap. + * + * @param api - pi's Google API (`googleGenerativeAIApi()`). + * @param levelModels - the ids on this plane the catalog describes by level + * (`usesCatalogThinkingLevels`). + * @param source - the host's pi pieces; `undefined` leaves the api pi's. + */ +export const withCatalogThinkingLevels = ( + api: unknown, + levelModels: ReadonlySet, + source?: GoogleLevelSource +): unknown => { + if ( + source === undefined || + levelModels.size === 0 || + !isRecord(api) || + !isFunctionLike(api.streamSimple) + ) { + return api; + } + const piStreamSimple = api.streamSimple; + const toPi = (args: readonly unknown[]): unknown => + Reflect.apply(piStreamSimple, api, args); + return { + ...api, + streamSimple: (...args: unknown[]): unknown => { + const [model, context, options] = args; + if ( + !isRecord(model) || + model.reasoning !== true || + typeof model.id !== "string" || + !levelModels.has(model.id) + ) { + return toPi(args); + } + return source.lazyStream(model, async () => { + const pieces = await source.load(); + if (pieces === undefined || pieces.usesGoogleThinkingLevel(model)) { + return toPi(args); + } + return levelStreamSimple( + pieces, + model, + context, + isRecord(options) ? options : undefined + ); + }); + }, + }; +}; diff --git a/src/host/host-modules.ts b/src/host/host-modules.ts new file mode 100644 index 00000000..efe0ffcc --- /dev/null +++ b/src/host/host-modules.ts @@ -0,0 +1,645 @@ +/** + * The default way the plugin reaches the host's own modules: pi-ai's (for + * `PiAiAdapter`, `Config` and `recordKeyFor`), and the libraries pi-ai itself + * runs on — pi, dsh-llm and the loader. + * + * None of them is a dependency of this package, on purpose: declaring one puts + * a second copy in the tree and turns every host bump into a version-skew bug. + * So each is asked of the host. pi-ai's module comes from pi-ai's own loader + * entry (the loader keeps the raw import on it before the first config run), + * and the libraries from the dependency tree of the wrapper the loader + * resolves from the context's base URL, which is where the copy pi-ai runs on + * lives — never from this plugin's own install, which under a `link:` is a + * checkout with its own development copies. + * + * `cordis-context.ts` holds the seam: {@link defaultHostModuleSource} is what + * it uses unless the harness replaced it. Every export check is narrowed; a + * module that moved an export answers `undefined` here, never a throw. + * + * @module dsh-opencode-patch/host/host-modules + */ + +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import type { + HostLibraries, + HostModuleSource, + PiAiModule, +} from "../cordis-context.ts"; +import { isFunctionLike, isRecord } from "../utils/guards.ts"; +import type { PlaneRoute, Protocol } from "../utils/protocols.ts"; +import type { GoogleLevelPieces, GoogleLevelSource } from "./google-levels.ts"; + +/** The host plugin this module reads. */ +const PI_AI_PACKAGE = "@deepseek-ai/dsh-llm-pi-ai"; + +/** + * Read a value that may be an array or any iterable as an array. + * + * The loader's `entries()` is a generator, so it is not an array; a mock or a + * future version may hand over either. + * + * @param value - the candidate. + * @returns the items, or `[]` when the value is not iterable. + */ +const toArray = (value: unknown): unknown[] => { + if (Array.isArray(value)) { + return value; + } + if (value === null || typeof value !== "object") { + return []; + } + const iterator: unknown = Reflect.get(value, Symbol.iterator); + if (typeof iterator !== "function") { + return []; + } + const items: unknown[] = []; + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the Symbol.iterator check above is the runtime proof + for (const item of value as Iterable) { + items.push(item); + } + return items; +}; + +/** + * The loader service, off the context or through the service registry. + * + * `ctx.loader` is the accessor cordis installs for the service; `ctx.get` is the + * untyped escape hatch, kept because a context that exposes only the latter is + * still a context this plugin can work in. + * + * @param ctx - the host context. + * @returns the loader, or `undefined` when the host serves none. + */ +const loaderOf = (ctx: unknown): Record | undefined => { + if (!isRecord(ctx)) { + return undefined; + } + // Guarded, because THIS read throws: cordis answers + // `cannot get property "loader" without inject` for an undeclared name, and + // this plugin declares no service at its root. An unguarded read here failed + // the whole mount, not just the lookup. + try { + const { loader: direct } = ctx as { loader?: unknown }; + if (isRecord(direct) && isFunctionLike(direct.entries)) { + return direct; + } + } catch { + // Not injected: the `ctx.get` path below is the one that works. + } + if (!isFunctionLike(ctx.get)) { + return undefined; + } + let viaGet: unknown; + try { + viaGet = Reflect.apply(ctx.get, ctx, ["loader"]); + } catch { + return undefined; + } + return isRecord(viaGet) && isFunctionLike(viaGet.entries) + ? viaGet + : undefined; +}; + +/** Every entry the host's loader holds, or `[]` when there is no loader. */ +const loaderEntries = (ctx: unknown): unknown[] => { + const loader = loaderOf(ctx); + if (loader === undefined) { + return []; + } + const { entries } = loader; + if (typeof entries !== "function") { + return []; + } + try { + return toArray(Reflect.apply(entries, loader, [])); + } catch { + return []; + } +}; + +/** + * The loader entry the composition loaded the host plugin under. + * + * @param ctx - host context carrying the loader. + * @returns the entry, or `undefined` when no entry names the package. + */ +const piAiEntry = (ctx: unknown): Record | undefined => { + const entry = loaderEntries(ctx).find( + (candidate) => + isRecord(candidate) && + isRecord(candidate.options) && + candidate.options.name === PI_AI_PACKAGE + ); + return isRecord(entry) ? entry : undefined; +}; + +/** + * The raw module namespace, for a loader entry that kept none. + * + * The plane adapter extends the wrapper's own `PiAiAdapter`, and the class + * lives on the module namespace — which one loader generation keeps on its + * entry and the other drops entirely, keeping only the fiber. The registry's + * runtime record does not keep it either: it holds + * `{ name, callback, fibers, Config }` and nothing else. + * + * The module is still reachable, because it is the SAME module: the loader + * resolved the entry's own name against the context's base URL, so resolving + * that name from the same base reaches the same file, and Node's module cache + * hands back the namespace the loader already executed. Nothing is imported + * twice and nothing is guessed from a package-manager layout. + * + * @param ctx - host context carrying the loader and its base URL. + * @returns the namespace, or `undefined` when no entry names the package or + * the module cannot be resolved from the base URL. + */ +export const loadPiAiModule = async ( + ctx: unknown +): Promise | undefined> => { + if (piAiEntry(ctx) === undefined) { + return undefined; + } + const base = isRecord(ctx) ? ctx.baseUrl : undefined; + if (typeof base !== "string" || base.length === 0) { + return undefined; + } + try { + // The base URL is the directory the loader resolves bare specifiers + // against, so the sibling file only anchors `createRequire` to that + // directory; it is never read. + const resolve = createRequire(new URL("noop.js", base)); + const namespace: unknown = await import( + pathToFileURL(resolve.resolve(PI_AI_PACKAGE)).href + ); + return isRecord(namespace) ? namespace : undefined; + } catch { + return undefined; + } +}; + +/** The wrapper whose own dependency tree names the pi the host installed. */ +const WRAPPER_PACKAGE = "@deepseek-ai/dsh-llm-pi-ai"; + +/** The pi installation's identity, as the host-version pin reads it. */ +export interface HostPi { + /** pi's own installed catalog, per provider id, exactly as pi exports it. */ + getBuiltinModels: (provider: string) => readonly { id: string }[]; + /** The version string of the copy that was loaded. */ + version: string; + /** The package root it was loaded from. */ + root: string; +} + +/** + * The wrapper's entry file, resolved the way the host's loader resolved it. + * + * The loader resolves pi-ai's row against the context's base URL (the + * profile's directory), and the host's resolution interception answers a bare + * request from there with the INSTALLATION's copy — the one the host runs. So + * resolving the wrapper from that same base reaches the host's copy, whether + * this plugin was installed into the profile or linked from a checkout: a + * linked checkout's own tree is never consulted. Only a context with no base + * URL, or one the wrapper cannot be resolved from (a bare cordis context in a + * test), falls back to resolving from this module's own location. + * + * @param ctx - the plugin context, whose `baseUrl` the loader set. + * @returns the entry's real path, or `undefined` when neither resolves. + */ +export const wrapperEntryFor = (ctx: unknown): string | undefined => { + let base: unknown; + try { + base = isRecord(ctx) ? ctx.baseUrl : undefined; + } catch { + base = undefined; + } + if (typeof base === "string" && base.length > 0) { + try { + // The sibling file only anchors `createRequire` to the base directory; + // it is never read. + return realpathSync( + createRequire(new URL("noop.js", base)).resolve(WRAPPER_PACKAGE) + ); + } catch { + // Not resolvable from the base: the plugin's own location below. + } + } + try { + // `import.meta.resolve` honours the wrapper's `import`/`default` + // conditions, which is what an ESM-only package declares. + return realpathSync(fileURLToPath(import.meta.resolve(WRAPPER_PACKAGE))); + } catch { + return undefined; + } +}; + +/** + * Where one of the wrapper's own dependencies lives, found by walking node's + * lookup from the WRAPPER's own directory. + * + * The wrapper declares pi as a dependency and dsh-llm and the loader as peers, + * so the copy node would hand IT is the copy every other consumer in that tree + * gets — which is the whole point: one instance, one version. The walk is + * explicit because pnpm's layout is not a plain `node_modules` beside the + * package: the wrapper's dependencies live in the `.pnpm` hosting directory + * above it (or in a hoisted store beside it), and under a `link:` or `file:` + * install they live somewhere else again. Walking the parent chain and testing + * for the package is the one rule that covers all of them without encoding a + * package-manager layout. + * + * @param wrapperEntry - the wrapper's entry file ({@link wrapperEntryFor}). + * @param scope - the package scope, `@earendil-works`. + * @param name - the package name within it, `pi-ai`. + * @returns the package ROOT, or `undefined` when the wrapper's tree carries + * no such package. + */ +const installedPackageRoot = ( + wrapperEntry: string, + scope: string, + name: string +): string | undefined => { + let dir = path.dirname(wrapperEntry); + // The entry is `/lib/index.js`; its `node_modules` walk starts at the + // package root, which the hosting directory above it also covers, so walking + // up from the entry's own directory reaches the same install either way. + for (let depth = 0; depth < 8; depth += 1) { + const candidate = path.join(dir, "node_modules", scope, name); + if (existsSync(path.join(candidate, "package.json"))) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + return undefined; +}; + +/** The pi modules the plane provider is composed from, relative to pi's root. */ +const PI_MODULES = { + all: "dist/providers/all.js", + anthropic: "dist/api/anthropic-messages.lazy.js", + completions: "dist/api/openai-completions.lazy.js", + go: "dist/providers/opencode-go.js", + google: "dist/api/google-generative-ai.lazy.js", + googleShared: "dist/api/google-shared.js", + googleStream: "dist/api/google-generative-ai.js", + headers: "dist/providers/opencode-headers.js", + lazy: "dist/api/lazy.js", + models: "dist/models.js", + responses: "dist/api/openai-responses.lazy.js", + simpleOptions: "dist/api/simple-options.js", + zen: "dist/providers/opencode.js", +} as const; + +/** A package's ESM entry file, read from its manifest. */ +const entryFileOf = (root: string): string | undefined => { + try { + const manifest: unknown = JSON.parse( + readFileSync(path.join(root, "package.json"), "utf-8") + ); + if (!isRecord(manifest)) { + return undefined; + } + const { exports, main } = manifest; + const dot: unknown = isRecord(exports) ? exports["."] : exports; + const target: unknown = isRecord(dot) ? (dot.import ?? dot.default) : dot; + const file = typeof target === "string" ? target : main; + return typeof file === "string" ? path.join(root, file) : undefined; + } catch { + return undefined; + } +}; + +/** Import a module by file path, answering `undefined` for anything but a record. */ +const importRecord = async ( + file: string | undefined +): Promise | undefined> => { + if (file === undefined) { + return undefined; + } + try { + const namespace: unknown = await import(pathToFileURL(file).href); + return isRecord(namespace) ? namespace : undefined; + } catch { + return undefined; + } +}; + +/** A function export of a module, or `undefined`. */ +const fn = ( + namespace: Record | undefined, + key: string +): ((...args: never[]) => unknown) | undefined => { + const value = namespace?.[key]; + return isFunctionLike(value) ? value : undefined; +}; + +/** A provider factory's `auth`, read off a fresh provider. */ +const authOf = ( + factory: ((...args: never[]) => unknown) | undefined +): unknown => { + try { + const provider: unknown = factory?.(); + return isRecord(provider) ? provider.auth : undefined; + } catch { + return undefined; + } +}; + +/** + * pi's pieces for a Google model the catalog describes by thinking level + * (`host/google-levels.ts`), each a public export (`./api/*`, `./models`). The + * Google ones pull in the Google SDK, so they load on the first call that + * needs them, once, as pi's own lazy Google API loads its module. + * + * @param piFile - a pi module's file, by key. + * @param lazy - `api/lazy`, loaded with the rest. + * @param models - `models`, loaded with the rest. + * @returns the source, or `undefined` when pi lacks `lazyStream` or + * `clampThinkingLevel`; its `load` answers `undefined` when pi lacks a piece. + */ +const googleLevelsFrom = ( + piFile: (key: keyof typeof PI_MODULES) => string, + lazy: Record | undefined, + models: Record | undefined +): GoogleLevelSource | undefined => { + const lazyStream = fn(lazy, "lazyStream"); + const clampThinkingLevel = fn(models, "clampThinkingLevel"); + if (lazyStream === undefined || clampThinkingLevel === undefined) { + return undefined; + } + let loaded: Promise | undefined; + const load = async (): Promise => { + const [google, shared, simple] = await Promise.all([ + importRecord(piFile("googleStream")), + importRecord(piFile("googleShared")), + importRecord(piFile("simpleOptions")), + ]); + const pieces = { + buildBaseOptions: fn(simple, "buildBaseOptions"), + clampThinkingLevel, + resolveGoogleThinkingLevel: fn(shared, "resolveGoogleThinkingLevel"), + stream: fn(google, "stream"), + toGoogleSdkThinkingLevel: fn(shared, "toGoogleSdkThinkingLevel"), + toGoogleThinkingLevel: fn(shared, "toGoogleThinkingLevel"), + usesGoogleThinkingLevel: fn(shared, "usesGoogleThinkingLevel"), + }; + return Object.values(pieces).some((piece) => piece === undefined) + ? undefined + : // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- pi's own signatures; every export was checked to be a function above. + (pieces as unknown as GoogleLevelPieces); + }; + return { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- pi's own signature; the export check above is the runtime guard. + lazyStream: lazyStream as GoogleLevelSource["lazyStream"], + load: () => { + loaded ??= load(); + return loaded; + }, + }; +}; + +/** + * Load the libraries: pi's provider pieces, dsh-llm's error and retry helpers, + * and the loader's `interpolate`, each from the given wrapper's own tree. + * + * @param wrapperEntry - the wrapper's entry file ({@link wrapperEntryFor}). + * @returns the libraries, or `undefined` when there is no wrapper, or pi or + * dsh-llm cannot be read from its tree. + */ +export const loadLibrariesFrom = async ( + wrapperEntry?: string +): Promise => { + if (wrapperEntry === undefined) { + return undefined; + } + const piRoot = installedPackageRoot(wrapperEntry, "@earendil-works", "pi-ai"); + const llmRoot = installedPackageRoot(wrapperEntry, "@deepseek-ai", "dsh-llm"); + if (piRoot === undefined || llmRoot === undefined) { + return undefined; + } + const loaderRoot = installedPackageRoot( + wrapperEntry, + "@deepseek-ai", + "cordis-plugin-loader" + ); + const piFile = (key: keyof typeof PI_MODULES): string => + path.join(piRoot, PI_MODULES[key]); + const [ + all, + anthropic, + completions, + go, + google, + headers, + lazy, + models, + responses, + zen, + llm, + loader, + ] = await Promise.all([ + importRecord(piFile("all")), + importRecord(piFile("anthropic")), + importRecord(piFile("completions")), + importRecord(piFile("go")), + importRecord(piFile("google")), + importRecord(piFile("headers")), + importRecord(piFile("lazy")), + importRecord(piFile("models")), + importRecord(piFile("responses")), + importRecord(piFile("zen")), + importRecord(entryFileOf(llmRoot)), + importRecord( + loaderRoot === undefined ? undefined : entryFileOf(loaderRoot) + ), + ]); + const apis: Partial unknown>> = { + "anthropic-messages": fn(anthropic, "anthropicMessagesApi"), + "google-generative-ai": fn(google, "googleGenerativeAIApi"), + "openai-completions": fn(completions, "openAICompletionsApi"), + "openai-responses": fn(responses, "openAIResponsesApi"), + }; + const createProvider = fn(models, "createProvider"); + const withSessionHeader = fn(headers, "withOpenCodeSessionHeader"); + const getBuiltinModels = fn(all, "getBuiltinModels"); + const resolveRetryPolicy = fn(llm, "resolveRetryPolicy"); + const opencodeAuth = authOf(fn(zen, "opencodeProvider")); + const opencodeGoAuth = authOf(fn(go, "opencodeGoProvider")); + if ( + createProvider === undefined || + withSessionHeader === undefined || + getBuiltinModels === undefined || + resolveRetryPolicy === undefined || + opencodeAuth === undefined || + opencodeGoAuth === undefined || + Object.values(apis).some((factory) => factory === undefined) + ) { + return undefined; + } + const LlmError = llm?.LlmError; + const assertUsableApiKey = fn(llm, "assertUsableApiKey"); + const interpolate = fn(loader, "interpolate"); + const googleLevels = googleLevelsFrom(piFile, lazy, models); + let version = ""; + try { + const manifest: unknown = JSON.parse( + readFileSync(path.join(piRoot, "package.json"), "utf-8") + ); + version = + isRecord(manifest) && typeof manifest.version === "string" + ? manifest.version + : ""; + } catch { + version = ""; + } + // pi's own signatures, as this module consumes them; the export checks + // above are the runtime guard these casts stand in for. + /* oxlint-disable typescript/no-unsafe-type-assertion */ + return { + apis: apis as HostLibraries["apis"], + builtinModels: (route: PlaneRoute) => + (getBuiltinModels as (id: string) => unknown)(route) as ReturnType< + HostLibraries["builtinModels"] + >, + createProvider: createProvider as HostLibraries["createProvider"], + opencodeAuth, + opencodeGoAuth, + resolveRetryPolicy: + resolveRetryPolicy as HostLibraries["resolveRetryPolicy"], + root: piRoot, + version, + withOpenCodeSessionHeader: + withSessionHeader as HostLibraries["withOpenCodeSessionHeader"], + ...(isFunctionLike(LlmError) + ? { LlmError: LlmError as unknown as HostLibraries["LlmError"] } + : {}), + ...(assertUsableApiKey === undefined + ? {} + : { + assertUsableApiKey: + assertUsableApiKey as HostLibraries["assertUsableApiKey"], + }), + ...(interpolate === undefined + ? {} + : { interpolate: interpolate as HostLibraries["interpolate"] }), + ...(googleLevels === undefined ? {} : { googleLevels }), + }; + /* oxlint-enable typescript/no-unsafe-type-assertion */ +}; + +/** + * The pi the HOST installed: its version, its root and its own + * `getBuiltinModels`. + * + * It walks to pi exactly as {@link loadLibrariesFrom} does, so the version pin + * in `test/host-versions.test.ts` reads the copy the plane provider is built + * from, and the function's identity proves there is one module instance. + * + * @param ctx - the plugin context, whose base URL the wrapper resolves from. + * @returns the identity, or `undefined` when the host's pi is not reachable. + */ +export const loadHostPi = async ( + ctx?: unknown +): Promise => { + const wrapper = wrapperEntryFor(ctx); + const root = + wrapper === undefined + ? undefined + : installedPackageRoot(wrapper, "@earendil-works", "pi-ai"); + if (root === undefined) { + return undefined; + } + try { + const manifest = readFileSync(path.join(root, "package.json"), "utf-8"); + const providers = await importRecord(path.join(root, PI_MODULES.all)); + const getBuiltinModels = fn(providers, "getBuiltinModels"); + if (getBuiltinModels === undefined) { + return undefined; + } + const manifestData: unknown = JSON.parse(manifest); + return { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- pi's own signature; the export check above is the runtime guard. + getBuiltinModels: getBuiltinModels as HostPi["getBuiltinModels"], + root, + version: + isRecord(manifestData) && typeof manifestData.version === "string" + ? manifestData.version + : "", + }; + } catch { + return undefined; + } +}; + +/** The libraries, loaded once per wrapper install for the life of the process. */ +const librariesByWrapper = new Map< + string, + Promise +>(); + +/** A record carrying pi-ai's three exports, or `undefined`. */ +export const asPiAiModule = (value: unknown): PiAiModule | undefined => + isRecord(value) && + isFunctionLike(value.PiAiAdapter) && + isFunctionLike(value.Config) && + isFunctionLike(value.recordKeyFor) + ? // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the three export checks above are the runtime guard. + (value as unknown as PiAiModule) + : undefined; + +/** + * pi-ai's module, from the fiber's own loader entry first: the loader stores + * the raw import on the entry before pi-ai's first config run + * (`loader/src/config/entry.ts:233-237`). The module resolved from the + * loader's base URL is the fallback, for a loader generation that kept none. + * + * @param ctx - the plugin context. + * @param fiber - pi-ai's fiber. + */ +const piAiFromFiber = async ( + ctx: unknown, + fiber: unknown +): Promise => { + const entry: unknown = isRecord(fiber) ? fiber.entry : undefined; + const namespace: unknown = isRecord(entry) + ? entry.moduleNamespace + : undefined; + if (namespace !== undefined) { + const direct = asPiAiModule(namespace); + if (direct !== undefined) { + return direct; + } + const loader = loaderOf(ctx); + const unwrapped = + loader !== undefined && isFunctionLike(loader.unwrapExports) + ? Reflect.apply(loader.unwrapExports, loader, [namespace]) + : undefined; + const viaLoader = asPiAiModule(unwrapped); + if (viaLoader !== undefined) { + return viaLoader; + } + } + return asPiAiModule(await loadPiAiModule(ctx)); +}; + +/** The source `cordis-context.ts` uses unless the harness replaced it. */ +export const defaultHostModuleSource: HostModuleSource = { + libraries: (ctx?: unknown) => { + const wrapper = wrapperEntryFor(ctx); + if (wrapper === undefined) { + return loadLibrariesFrom(); + } + let loaded = librariesByWrapper.get(wrapper); + if (loaded === undefined) { + loaded = loadLibrariesFrom(wrapper); + librariesByWrapper.set(wrapper, loaded); + } + return loaded; + }, + piAi: piAiFromFiber, +}; diff --git a/src/host/plane-provider.ts b/src/host/plane-provider.ts new file mode 100644 index 00000000..d5292a49 --- /dev/null +++ b/src/host/plane-provider.ts @@ -0,0 +1,581 @@ +/** + * The adapter the plugin registers under `opencode` and `opencode-go`: the + * host's own `PiAiAdapter`, over profiles the plugin resolves (D13). + * + * `PiAiAdapter` is a public export of pi-ai, and it already serves every + * protocol per model, validates efforts against each model, projects replay + * state and normalizes failures. What the plugin adds is only what pi-ai's own + * profiles cannot say: a model pi's catalog does not describe, the endpoint + * family, and the Off/On names of a toggle model. So the subclass overrides + * three methods and no stream: + * + * - `listModels` answers the declared ids when `super` answers nothing or the + * key is missing (F18): a plane lists its models whether or not a key + * resolves, and dispatch still fails loudly (D21); + * - `resolveModel` and `prepareCall` relabel a toggle model's `high` as "On" + * (D1). Ids, validation and the stream are pi-ai's. + * + * pi-ai exports neither its credential store nor its auth context (F19), so + * both are restated here over the same seams, keyed by pi-ai's exported + * `recordKeyFor`, and the key resolution mirrors pi-ai's exactly (D20). + * + * @module dsh-opencode-patch/host/plane-provider + */ + +import type { ResolvedPluginConfig } from "../config.ts"; +import { + type HostLibraries, + type HostModules, + readLaunchEnvironment, +} from "../cordis-context.ts"; +import { resolveOpencodeStoreKey } from "../go-discovery.ts"; +import { isFunctionLike, isRecord } from "../utils/guards.ts"; +import type { PlaneProviderBuilder } from "../utils/plane-profile.ts"; +import { PROTOCOLS } from "../utils/protocols.ts"; +import { withCatalogThinkingLevels } from "./google-levels.ts"; + +/** A resolved plane profile, as far as this module reads one. */ +export interface PlaneProfileLike { + readonly apiKeyEnv?: string; + readonly piProvider?: unknown; + readonly [key: string]: unknown; +} + +/** A model listing row, the shape `LlmAdapter.listModels` answers. */ +interface ListedModel { + readonly provider: string; + readonly id: string; + readonly name: string; + readonly inputModalities?: readonly string[]; +} + +/** + * The `PiAiAdapter` surface the subclass calls through `super`. Method + * signatures on purpose: a subclass overrides a method, never a property. + */ +/* oxlint-disable typescript/method-signature-style */ +interface PiAiAdapterLike { + listModels(provider: string): Promise; + resolveModel( + provider: string, + model: string, + signal?: AbortSignal + ): Promise; + prepareCall( + provider: string, + model: string, + signal?: AbortSignal + ): Promise; +} +/* oxlint-enable typescript/method-signature-style */ + +/** What {@link createPlaneAdapter} takes. */ +export interface PlaneAdapterInputs { + readonly modules: HostModules; + readonly profiles: () => ReadonlyMap; + /** The display names a model's efforts carry, when the catalog decided them. */ + readonly effortNames: ( + provider: string, + model: string + ) => Readonly> | undefined; + readonly resolveApiKey: ( + provider: string, + profile: PlaneProfileLike + ) => Promise; + readonly auth: PlaneAuth; + readonly resolveAttachments?: () => unknown; + readonly resolveImageAccess?: (store: unknown, ref: unknown) => unknown; + readonly onReplayDegrade?: (event: { + provider: string; + model: string; + reason: string; + }) => void; +} + +/** The error code of a thrown value, when it has one. */ +const codeOf = (error: unknown): unknown => + isRecord(error) ? error.code : undefined; + +/** + * Model info with its efforts renamed (D1's "On"), or the same object when + * there is nothing to rename. Ids are never touched: dispatch matches them. + * + * @param info - what `resolveModel` answered. + * @param names - display names by effort id, when the catalog decided them. + */ +export const relabelEfforts = ( + info: unknown, + names?: Readonly> +): unknown => { + if (names === undefined || !isRecord(info) || !isRecord(info.reasoning)) { + return info; + } + const { efforts } = info.reasoning; + if (!Array.isArray(efforts)) { + return info; + } + return { + ...info, + reasoning: { + ...info.reasoning, + efforts: efforts.map((effort: unknown) => + isRecord(effort) && typeof effort.id === "string" + ? { ...effort, name: names[effort.id] ?? effort.name } + : effort + ), + }, + }; +}; + +/** + * Build the plane adapter: a subclass of the host's `PiAiAdapter`. + * + * @param inputs - the host modules, the profiles and the auth seams. + * @returns the adapter, whose `constructor.name` is `PlaneAdapter`. + */ +export const createPlaneAdapter = (inputs: PlaneAdapterInputs): object => { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- pi-ai's exported class, checked to be a constructor by `asPiAiModule`; its methods are the ones `PiAiAdapterLike` names. + const Base = inputs.modules.piAi.PiAiAdapter as unknown as new ( + options: Record + ) => PiAiAdapterLike; + + class PlaneAdapter extends Base { + override async listModels( + provider: string + ): Promise { + let listed: readonly ListedModel[]; + try { + listed = await super.listModels(provider); + } catch (error) { + if (codeOf(error) !== "MISSING_CREDENTIAL") { + throw error; + } + listed = []; + } + if (listed.length > 0) { + return listed; + } + // pi-ai answers [] on a missing key (F18); the plane still lists what + // the user declared, and the turn is what fails, loudly. + const piProvider = inputs.profiles().get(provider)?.piProvider; + const models: unknown = + isRecord(piProvider) && isFunctionLike(piProvider.getModels) + ? Reflect.apply(piProvider.getModels, piProvider, []) + : []; + return (Array.isArray(models) ? models : []).flatMap((model: unknown) => + isRecord(model) && typeof model.id === "string" + ? [ + { + id: model.id, + inputModalities: Array.isArray(model.input) + ? model.input.filter( + (item): item is string => typeof item === "string" + ) + : ["text"], + name: typeof model.name === "string" ? model.name : model.id, + provider, + }, + ] + : [] + ); + } + + override async resolveModel( + provider: string, + model: string, + signal?: AbortSignal + ): Promise { + return relabelEfforts( + await super.resolveModel(provider, model, signal), + inputs.effortNames(provider, model) + ); + } + + override async prepareCall( + provider: string, + model: string, + signal?: AbortSignal + ): Promise { + const prepared = await super.prepareCall(provider, model, signal); + return isRecord(prepared) + ? { + ...prepared, + model: relabelEfforts( + prepared.model, + inputs.effortNames(provider, model) + ), + } + : prepared; + } + } + // A bundler may rename a class it emits; the probe and the tests read this. + Object.defineProperty(PlaneAdapter, "name", { value: "PlaneAdapter" }); + // Which pi the provider is built from: the binary e2e's probe asserts it is + // the host's own copy, not a linked checkout's. + Object.defineProperty(PlaneAdapter, "hostPi", { + value: Object.freeze({ + root: inputs.modules.libraries.root, + version: inputs.modules.libraries.version, + }), + }); + + return new PlaneAdapter({ + auth: inputs.auth, + profiles: inputs.profiles, + resolveApiKey: inputs.resolveApiKey, + ...(inputs.resolveAttachments === undefined + ? {} + : { resolveAttachments: inputs.resolveAttachments }), + ...(inputs.resolveImageAccess === undefined + ? {} + : { resolveImageAccess: inputs.resolveImageAccess }), + ...(inputs.onReplayDegrade === undefined + ? {} + : { onReplayDegrade: inputs.onReplayDegrade }), + }); +}; + +/** A pi credential, as pi-ai maps one from a stored record. */ +type PiCredential = Readonly>; + +/** The credentials service, as far as the auth seams read it. */ +interface CredentialsLike { + resolve?: (ref: string) => Promise; + readRecord?: (key: string) => Promise; + listRecords?: () => Promise; + modifyRecord?: ( + key: string, + mutate: (current: unknown) => Promise + ) => Promise; + deleteRecord?: (key: string) => Promise; +} + +/** pi's `CredentialStore` and `AuthContext`, over the host's seams. */ +export interface PlaneAuth { + readonly credentials: { + read: (providerId: string) => Promise; + list: () => Promise<{ providerId: string; type: string }[]>; + modify: ( + providerId: string, + mutate: (current?: PiCredential) => Promise + ) => Promise; + delete: (providerId: string) => Promise; + }; + readonly authContext: { + env: (name: string) => Promise; + fileExists: (path: string) => Promise; + }; +} + +/** The record id grammar (`dsh-credentials` `KEY_SEGMENT_PATTERN`). */ +const KEY_SEGMENT = /^[a-z][a-z0-9-]*$/u; + +/** The credential-ref grammar (`dsh-credentials` `REF_PATTERN`). */ +const REF_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u; + +/** The scope pi-ai stores its records under (`auth.ts` `RECORD_SCOPE`). */ +const RECORD_SCOPE = "llm-pi-ai"; + +/** The credentials service, read per call. */ +const credentialsOf = (ctx: unknown): CredentialsLike | undefined => { + if (!isRecord(ctx) || !isFunctionLike(ctx.get)) { + return undefined; + } + const service: unknown = Reflect.apply(ctx.get, ctx, ["credentials"]); + return isRecord(service) ? service : undefined; +}; + +/** pi-ai's `toPiCredential` (`auth.ts:74-88`). */ +const toPiCredential = (record: unknown): PiCredential | undefined => { + if (!isRecord(record)) { + return undefined; + } + if (record.kind === "api-key") { + return { + type: "api_key", + ...(record.key === undefined ? {} : { key: record.key }), + ...(isRecord(record.env) ? { env: { ...record.env } } : {}), + }; + } + return isRecord(record.payload) ? record.payload : undefined; +}; + +/** The JSON image of a grant payload (`auth.ts` `jsonImage`). */ +const jsonImage = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map((entry: unknown) => + entry === undefined ? null : jsonImage(entry) + ); + } + if (isRecord(value) && Object.getPrototypeOf(value) === Object.prototype) { + const image: Record = {}; + for (const [key, member] of Object.entries(value)) { + if (member !== undefined) { + image[key] = jsonImage(member); + } + } + return image; + } + return value; +}; + +/** pi-ai's `toRecord` (`auth.ts:90-100`). */ +const toRecord = (credential: PiCredential): Record => + credential.type === "api_key" + ? { + kind: "api-key", + ...(credential.key === undefined ? {} : { key: credential.key }), + ...(isRecord(credential.env) ? { env: { ...credential.env } } : {}), + } + : { kind: "grant", payload: jsonImage(credential) }; + +/** An error carrying a host failure code, in dsh-llm's class when there is one. */ +export const hostError = ( + libraries: HostLibraries | undefined, + message: string, + code: string +): Error => { + if (libraries?.LlmError !== undefined) { + return new libraries.LlmError(message, code); + } + const error = new Error(message) as Error & { + code?: string; + failure?: unknown; + }; + error.code = code; + error.failure = Object.freeze({ code, message }); + return error; +}; + +/** + * pi's credential store and auth context over the host's seams, as pi-ai + * builds its own (`llm-pi-ai/src/auth.ts:141-231`): records under + * `recordKeyFor(provider)`, so a Sign-in record under `llm-pi-ai/opencode` + * keeps authenticating the plane, and `env` asks the credentials service + * before the launch environment. OpenCode's auth reads no file. + * + * @param ctx - the plugin context. + * @param recordKeyFor - pi-ai's exported record addressing. + * @param libraries - for the host's error class. + */ +export const planeAuth = ( + ctx: unknown, + recordKeyFor: (providerId: string) => string, + libraries?: HostLibraries +): PlaneAuth => ({ + authContext: { + env: async (name: string): Promise => { + if (REF_NAME.test(name)) { + const hit: unknown = await credentialsOf(ctx)?.resolve?.(name); + if (hit !== undefined) { + return isRecord(hit) && typeof hit.value === "string" + ? hit.value + : undefined; + } + } + return readLaunchEnvironment(ctx).get(name)?.value; + }, + fileExists: (): Promise => Promise.resolve(false), + }, + credentials: { + delete: async (providerId: string): Promise => { + if (!KEY_SEGMENT.test(providerId)) { + return; + } + await writable(ctx, libraries).deleteRecord?.(recordKeyFor(providerId)); + }, + list: async (): Promise<{ providerId: string; type: string }[]> => { + const stored: unknown = await credentialsOf(ctx)?.listRecords?.(); + return (Array.isArray(stored) ? stored : []).flatMap((entry: unknown) => { + if (!isRecord(entry) || typeof entry.key !== "string") { + return []; + } + const slash = entry.key.indexOf("/"); + return entry.key.slice(0, slash) === RECORD_SCOPE + ? [ + { + providerId: entry.key.slice(slash + 1), + type: entry.kind === "api-key" ? "api_key" : "oauth", + }, + ] + : []; + }); + }, + modify: async ( + providerId: string, + mutate: (current?: PiCredential) => Promise + ): Promise => { + if (!KEY_SEGMENT.test(providerId)) { + throw hostError( + libraries, + `dsh-opencode-patch: provider id "${providerId}" cannot address a stored credential record; authenticate this route through apiKeyEnv instead`, + "UNSTORABLE_PROVIDER_ID" + ); + } + const store = writable(ctx, libraries); + return toPiCredential( + await store.modifyRecord?.( + recordKeyFor(providerId), + async (current: unknown) => { + const next = await mutate(toPiCredential(current)); + return next === undefined ? undefined : toRecord(next); + } + ) + ); + }, + read: async (providerId: string): Promise => { + const credentials = credentialsOf(ctx); + if (credentials === undefined || !KEY_SEGMENT.test(providerId)) { + return undefined; + } + return toPiCredential( + await credentials.readRecord?.(recordKeyFor(providerId)) + ); + }, + }, +}); + +/** The credentials service for a write, or the refusal pi-ai raises. */ +const writable = ( + ctx: unknown, + libraries: HostLibraries | undefined +): CredentialsLike => { + const credentials = credentialsOf(ctx); + if (credentials === undefined) { + throw hostError( + libraries, + "dsh-opencode-patch: this composition mounts no credentials service, so there is nowhere to store the credential a sign-in produces", + "NO_CREDENTIAL_STORE" + ); + } + return credentials; +}; + +/** The variable pi's own OpenCode auth reads, on both planes (`providers/opencode*.js`). */ +const PI_OPENCODE_KEY_ENV = "OPENCODE_API_KEY"; + +/** + * Whether pi's own lookup would authenticate the route: a stored record of any + * kind owns the provider (`auth/resolve.js`), else `OPENCODE_API_KEY` through + * the auth context. + */ +const piLookupAnswers = async ( + auth: PlaneAuth, + provider: string +): Promise => { + if ((await auth.credentials.read(provider)) !== undefined) { + return true; + } + const ambient = await auth.authContext.env(PI_OPENCODE_KEY_ENV); + return ambient !== undefined && ambient.length > 0; +}; + +/** + * The plane's key, by pi-ai's own rule (D20; `llm-pi-ai/src/index.ts:188-211`): + * + * - no reference named: pi's own lookup applies (`undefined`) — a stored + * Sign-in record for the route, then `OPENCODE_API_KEY` through the + * credentials service and the launch environment. Only when that lookup + * would find nothing, and the deployment opted in, does OpenCode's own store + * answer: the store is the LAST resort, so an explicit key here must never + * pre-empt pi's (pi lets the request's `apiKey` win over its own lookup); + * - a named reference: the credentials service when one is mounted (a failure + * there propagates), the launch environment only without one; + * - a miss: `MISSING_CREDENTIAL`, and no request is sent. + * + * A captured key or a literal one never answers here: those are the fetch + * patch's fill for an empty wire key, not the adapter's credential. + * + * @param ctx - the plugin context. + * @param modules - for the host's error and key helpers. + * @param config - the plugin's settings (`readOpencodeStore`). + * @param auth - pi's credential store and auth context, as the adapter hands + * them to pi; defaults to {@link planeAuth} over the same seams. + */ +export const planeApiKeyResolver = + ( + ctx: unknown, + modules: HostModules, + config: Pick, + auth: PlaneAuth = planeAuth( + ctx, + modules.piAi.recordKeyFor, + modules.libraries + ) + ) => + async ( + provider: string, + profile: PlaneProfileLike + ): Promise => { + const ref = profile.apiKeyEnv; + if (ref === undefined) { + if ( + !config.readOpencodeStore || + (await piLookupAnswers(auth, provider)) + ) { + return undefined; + } + return resolveOpencodeStoreKey(provider, config.readOpencodeStore); + } + const credentials = credentialsOf(ctx); + let hit: string | undefined; + if (credentials === undefined) { + hit = readLaunchEnvironment(ctx).get(ref)?.value; + } else { + const resolved: unknown = await credentials.resolve?.(ref); + hit = + isRecord(resolved) && typeof resolved.value === "string" + ? resolved.value + : undefined; + } + if (hit !== undefined && hit.length > 0) { + const { assertUsableApiKey } = modules.libraries; + return assertUsableApiKey === undefined + ? hit.trim() + : assertUsableApiKey(hit, "dsh-opencode-patch", ref); + } + throw hostError( + modules.libraries, + `dsh-opencode-patch: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not set — store ${ref} through the credentials service (the web Models page writes it) or export it, and remove apiKeyEnv only if this provider should authenticate from pi's own environment discovery`, + "MISSING_CREDENTIAL" + ); + }; + +/** + * pi's provider for one plane: `createProvider` over all four protocols, each + * wrapped with pi's own OpenCode session header, and pi's own OpenCode auth so + * a profile naming no key keeps pi's ambient discovery. Google is wired on Go + * too, which pi 1.1.0's own Go provider is not (F10), and serves the ids the + * catalog describes by thinking level by level (`host/google-levels.ts`, D16). + * + * @param libraries - the host's pi. + */ +export const buildPlaneProvider = + (libraries: HostLibraries): PlaneProviderBuilder => + ({ id, levelThinkingModels = new Set(), models, name }) => + libraries.createProvider({ + api: Object.fromEntries( + PROTOCOLS.map((protocol) => { + const api = libraries.apis[protocol](); + return [ + protocol, + libraries.withOpenCodeSessionHeader( + protocol === "google-generative-ai" + ? withCatalogThinkingLevels( + api, + levelThinkingModels, + libraries.googleLevels + ) + : api + ), + ]; + }) + ), + auth: + id === "opencode-go" + ? libraries.opencodeGoAuth + : libraries.opencodeAuth, + id, + models, + name, + }); diff --git a/src/host/route-claim.ts b/src/host/route-claim.ts new file mode 100644 index 00000000..8cef75aa --- /dev/null +++ b/src/host/route-claim.ts @@ -0,0 +1,837 @@ +/** + * pi-ai never claims the planes; the plugin registers its own adapter under + * them (D13, docs/improvement-plan.md "The design"). + * + * Four parts, each on a supported host path: + * + * - **The listener.** A prepended `internal/config` listener on the plugin's + * root. For the pi-ai fiber it serves only (`this.runtime?.name === + * "llm-pi-ai"`; see {@link installRouteClaim}'s `servedRow`: the served row + * is sticky while it is live and declares a plane, else the first live row + * that declares one, else the first live row); another pi-ai row keeps its + * planes and serves them itself, and is reported once — it + * runs the nested waterfall on a fresh copy without the two plane keys and + * returns that, so pi-ai resolves and registers every other route and never + * sees the planes (F11, F12) — its own save check included, which a strip + * that let the raw value through would not shield. The user's file keeps + * every entry: the editor writes the raw value, never the resolved one. A + * commit run (`raw === this._config`, F14) schedules {@link reconcile}; an + * editor candidate has its changed plane entries validated strictly, and a + * malformed one is refused in pi-ai's message style. A commit run never + * throws: that would fail pi-ai's fiber and every route it holds. + * + * **A constraint this places on the host (F12).** cordis's + * `Fiber.update(config, noSave)` hands the RESOLVED value to + * `internal/update`, where the loader writes it to the row's options and + * the profile unless `noSave` is set (`cordis/src/fiber.ts:736-753`, + * `loader/src/index.ts:115-121`). So a `piFiber.update(raw)` with `noSave` + * false would persist this copy and delete both planes from the user's + * file. No caller does that today — the editor writes the raw value, and + * the loader's own update passes `noSave = true` + * (`loader/src/config/entry.ts:106`) — and the same path would already + * write interpolated `!!js` values for any plugin. Guarding it waits for a + * real caller. + * - **Reconcile.** Reads the committed plane entries from pi-ai's + * `_config.providers`, interpolates them with the loader's `interpolate` + * (F16), normalizes them with pi-ai's `Config`, builds the profiles + * (`utils/plane-profile.ts`), and registers the declared planes with + * `registerAdapter`, or moves them with `handle.replace` when the route set, + * a `displayName` or a retry policy changed. It also runs on every + * `llm/adapters-updated`, on pi-ai's `loader/partial-dispose`, and when a + * catalog refresh lands (the memo key holds the catalog's generation). + * When the served pi-ai changes — only when it is disposed or stops + * declaring a plane, so the previous one never has a stripped plane to take + * back — the previous one's handle and profiles are dropped first, and a new + * served row already holding a plane is restarted once it settles, as the + * mount does. + * - **Mount.** Inside `apply`'s promise, never on a timer: when pi-ai applied + * first and already holds a plane, wait for it to settle and restart it + * once, so it re-resolves through the listener (F17) — unless the host's + * modules cannot be read, when it stands down with no restart at all. + * - **Teardown.** One effect, registered first so it runs last: release the + * handle and go inert with no await between; then, only on a real dispose + * (`ctx.fiber.uid === null`) with pi-ai live and its parent not unloading, + * wait for pi-ai to settle and restart it if it resolved without a plane it + * declares, so it takes the planes back — a pi-ai that was LOADING resolved + * through the still-active listener too. A reload of the plugin's own row + * restarts pi-ai zero times, and unsubscribes from the catalog all the + * same. The stand-down when the host's modules cannot be read hands back + * the same way, whether or not a plane is declared yet, so a plane a + * Settings save adds later goes to pi-ai's own save check. + * + * The registry and `adapter.config` are never read: what pi-ai holds is read + * from its fiber's resolved config. + * + * @module dsh-opencode-patch/host/route-claim + */ + +import { attachmentResolvers } from "../attachment-services.ts"; +import { OPENCODE_FAMILY_API } from "../catalog-data.ts"; +import type { ResolvedPluginConfig } from "../config.ts"; +import { + type AdapterRegistrationLike, + type CordisContext, + type HostFiber, + type HostModules, + loadHostModules, + PI_AI_RUNTIME, + piAiFibers, +} from "../cordis-context.ts"; +import { + catalogGeneration, + getLiveGoCatalog, + getLiveZenCatalog, + onCatalogChange, +} from "../models-catalog.ts"; +import { isFunctionLike, isRecord } from "../utils/guards.ts"; +import { + hasPlaneEntries, + planeEntriesOf, + planeProfileFor, + withoutPlaneProviders, +} from "../utils/plane-profile.ts"; +import { PLANE_ROUTES, type PlaneRoute } from "../utils/protocols.ts"; +import { + buildPlaneProvider, + createPlaneAdapter, + planeApiKeyResolver, + planeAuth, + type PlaneProfileLike, +} from "./plane-provider.ts"; + +/** cordis `FiberState`. */ +const ACTIVE = 2; +const FAILED = 3; +const UNLOADING = 5; + +/** What {@link installRouteClaim} hands the rest of `apply`. */ +export interface RouteClaim { + /** The interpolated, normalized committed plane entries, for the Settings graft. */ + readonly committedEntries: () => Readonly< + Partial> + >; + /** The settings namespace pi-ai's row is described under. */ + readonly piAiNamespace: () => string; + /** Settles once the claim is mounted; never rejects. */ + readonly mounted: Promise; +} + +/** The `llm` service, as the claim calls it. */ +interface LlmLike { + registerAdapter?: ( + providers: readonly string[], + adapter: unknown + ) => AdapterRegistrationLike | undefined; + listProviders?: () => unknown; +} + +const LOG = "[dsh-opencode-patch]"; + +/** Whether a fiber is one of pi-ai's (never by `fiber.name`, which inject children inherit). */ +const isPiAi = (fiber: unknown): fiber is HostFiber => + isRecord(fiber) && + isRecord(fiber.runtime) && + fiber.runtime.name === PI_AI_RUNTIME; + +/** The plane keys a fiber's RESOLVED config holds: what pi-ai has registered. */ +const resolvedPlanes = (fiber: HostFiber): PlaneRoute[] => { + try { + const config: unknown = fiber.config; + const providers: unknown = isRecord(config) ? config.providers : undefined; + const value: unknown = + isRecord(providers) && isFunctionLike(providers.get) + ? Reflect.apply(providers.get, providers, []) + : providers; + return isRecord(value) + ? PLANE_ROUTES.filter((plane) => Object.hasOwn(value, plane)) + : []; + } catch { + return []; + } +}; + +/** Whether a fiber resolved without a plane its raw config declares. */ +const lacksDeclaredPlane = (fiber: HostFiber): boolean => { + const declared = planeEntriesOf(fiber._config); + const held = resolvedPlanes(fiber); + return PLANE_ROUTES.some( + (plane) => Object.hasOwn(declared, plane) && !held.includes(plane) + ); +}; + +/** + * Restart pi-ai, once it settles, if it is serving without a plane its raw + * config declares — a pi-ai that was LOADING when the claim went inert + * resolved through the still-active listener, and nobody else would ever + * restart it. One that resolved after the claim went inert holds its planes + * and is left alone. + */ +const restartOnceSettled = async (pi: HostFiber): Promise => { + try { + await pi.await?.(); + } catch { + // A FAILED pi-ai is read as such below. + } + if (pi.uid !== null && pi.state === ACTIVE && lacksDeclaredPlane(pi)) { + await pi.restart?.(); + } +}; + +const codeOf = (error: unknown): unknown => + isRecord(error) ? error.code : undefined; + +/** + * Install the claim. Runs synchronously up to its first await, so the + * teardown is the first effect `apply` registers and the listener is in place + * before any `llm` scope exists. + * + * @param ctx - the plugin context (its root fiber). + * @param config - the plugin's resolved settings. + * @returns the claim's readers and its mount. + */ +export const installRouteClaim = ( + ctx: CordisContext, + config: Pick +): RouteClaim => { + const state = { + active: true, + adapter: undefined as object | undefined, + effortNames: new Map< + string, + ReadonlyMap>> + >(), + entriesKey: undefined as string | undefined, + facts: undefined as string | undefined, + handle: undefined as AdapterRegistrationLike | undefined, + llm: undefined as LlmLike | undefined, + modules: undefined as HostModules | undefined, + /** + * The last modules that loaded, for judging a Settings save. Never + * cleared by a handover or pi-ai's partial dispose: validation reads only + * pi-ai's `Config`, the libraries and pi's catalog, none of which change + * between two loads short of a hot reload, which the next reconcile + * re-reads anyway. So a valid save is not refused for timing. + */ + validationModules: undefined as HostModules | undefined, + normalized: {} as Partial>, + pendingDuplicate: false, + profiles: new Map() as ReadonlyMap< + string, + PlaneProfileLike + >, + ready: false, + reentrant: false, + scheduled: false, + /** The fiber the profiles and the handle were built from. */ + served: undefined as HostFiber | undefined, + /** Rows serving a plane themselves that were already reported. */ + warnedHolders: new WeakSet(), + warnedUnavailable: false, + }; + const warn = (message: string, ...rest: unknown[]): void => { + ctx.logger?.warn?.(`${LOG} ${message}`, ...rest); + }; + + const missing = ["on", "inject", "effect"].filter( + (key) => !isFunctionLike(Reflect.get(ctx, key)) + ); + if (missing.length > 0) { + warn( + `the plane routes stay pi-ai's: this composition offers no ${missing.join(", ")}` + ); + return { + committedEntries: () => ({}), + mounted: Promise.resolve(), + piAiNamespace: () => PI_AI_RUNTIME, + }; + } + + const images = attachmentResolvers(ctx); + + /** + * The pi-ai fiber the claim serves. Sticky: the row already served stays + * served while it is not disposed and its raw config still declares a + * plane. Otherwise the first row not disposed, whatever its state, whose + * raw config DECLARES a plane — else the first row not disposed. Chosen by + * declaration, not by registry order alone, because cordis appends a + * re-enabled row's new fiber to the end of the registry: by order, a + * plane-less row ahead of it would be served and the planes never claimed. + * Sticky, because only the served fiber's planes are stripped: were the + * served row to move between two live declaring rows, the one left behind + * would keep its stripped config and nobody would serve its planes. The + * served row therefore moves only when it is gone or has nothing to strip, + * and another pi-ai row keeps its own planes and serves them itself. + */ + const servedRow = (): HostFiber | undefined => { + const live = piAiFibers(ctx).filter((fiber) => fiber.uid !== null); + const current = state.served; + if ( + current !== undefined && + live.includes(current) && + hasPlaneEntries(current._config) + ) { + return current; + } + return live.find((fiber) => hasPlaneEntries(fiber._config)) ?? live[0]; + }; + + /** The served pi-ai fiber while it is not FAILED: a FAILED one has nothing to claim. */ + const livePiAi = (): HostFiber | undefined => { + const fiber = servedRow(); + return fiber?.state === FAILED ? undefined : fiber; + }; + + /** + * Profiles and adapter for the committed entries; memoized by their JSON and + * the catalog's generation, so a refresh that lands after boot reaches the + * profiles (D3's default list, D16's protocol, D17's metadata). + */ + const rebuild = (pi: HostFiber, modules: HostModules): void => { + const entries = planeEntriesOf(pi._config); + const key = `${catalogGeneration()}:${JSON.stringify(entries)}`; + if (key === state.entriesKey && state.adapter !== undefined) { + return; + } + const { libraries, piAi } = modules; + const profiles = new Map(); + const effortNames = new Map< + string, + ReadonlyMap>> + >(); + const normalized: Partial> = {}; + const { interpolate } = libraries; + for (const route of PLANE_ROUTES) { + if (!Object.hasOwn(entries, route)) { + continue; + } + const result = planeProfileFor({ + buildProvider: buildPlaneProvider(libraries), + catalog: + route === "opencode-go" ? getLiveGoCatalog() : getLiveZenCatalog(), + endpoint: config.endpoint, + entry: entries[route], + familyApi: + route === "opencode-go" + ? OPENCODE_FAMILY_API.go + : OPENCODE_FAMILY_API.zen, + ...(interpolate === undefined + ? {} + : { interpolate: (value: unknown) => interpolate(pi.ctx, value) }), + normalize: (entry: unknown) => { + const resolved: unknown = piAi.Config({ + providers: { [route]: entry }, + }); + const providers: unknown = isRecord(resolved) + ? resolved.providers + : undefined; + const value: unknown = + isRecord(providers) && isFunctionLike(providers.get) + ? Reflect.apply(providers.get, providers, []) + : providers; + return isRecord(value) ? value[route] : undefined; + }, + piModels: libraries.builtinModels(route), + resolveRetryPolicy: libraries.resolveRetryPolicy, + route, + }); + profiles.set(route, result.profile); + effortNames.set(route, result.effortNames); + if (result.normalized !== undefined) { + normalized[route] = result.normalized; + } + } + state.profiles = profiles; + state.effortNames = effortNames; + state.normalized = normalized; + state.entriesKey = key; + if (state.adapter === undefined) { + // One auth for both: the resolver asks pi's own lookup through the same + // seams the adapter hands pi, so the store stays the last resort. + const auth = planeAuth(ctx, piAi.recordKeyFor, libraries); + state.adapter = createPlaneAdapter({ + auth, + effortNames: (provider, model) => + state.effortNames.get(provider)?.get(model), + modules, + onReplayDegrade: ({ model, provider, reason }) => { + warn( + `unusable replay state on assistant history for route "${provider}/${model}"; sending that message as provider-neutral content (${reason})` + ); + }, + profiles: () => state.profiles, + resolveApiKey: planeApiKeyResolver(ctx, modules, config, auth), + resolveAttachments: images.resolveAttachments, + resolveImageAccess: images.resolveImageAccess, + }); + } + }; + + /** Register, or move, the declared planes. */ + const register = (): void => { + const { llm, adapter } = state; + if (llm === undefined || adapter === undefined) { + return; + } + const routes = [...state.profiles.keys()]; + const facts = JSON.stringify( + routes.toSorted().map((route) => { + const profile = state.profiles.get(route); + return { + displayName: profile?.displayName, + retryPolicy: profile?.retryPolicy, + route, + }; + }) + ); + try { + if (state.handle === undefined) { + if (routes.length > 0) { + state.handle = llm.registerAdapter?.(routes, adapter); + } + } else if (facts !== state.facts) { + state.handle.replace?.(routes); + } + state.facts = facts; + } catch (error) { + if (codeOf(error) === "DUPLICATE_ADAPTER") { + // Another registration still holds a plane: pi-ai mid-restart, most + // likely. The next registry change retries, after pi-ai settles. + state.pendingDuplicate = true; + ctx.logger?.info?.( + `${LOG} a plane route is still held elsewhere; retrying once the registry changes` + ); + return; + } + warn("the plane routes did not register", error); + } + }; + + /** + * Restart a served pi-ai that resolved holding a plane, once it settles, so + * it re-resolves through the listener and the planes become the plugin's + * (the mount's restart, and a handover's). + */ + const restartHolder = async (fiber: HostFiber): Promise => { + try { + await fiber.await?.(); + } catch { + // A FAILED pi-ai holds nothing; the check below says so. + } + if ( + state.active && + fiber === servedRow() && + fiber.uid !== null && + fiber.state === ACTIVE && + resolvedPlanes(fiber).length > 0 + ) { + await fiber.restart?.(); + } + }; + + /** The once-only report that the host's modules cannot be read. */ + const warnUnavailable = (): void => { + if (!state.warnedUnavailable) { + state.warnedUnavailable = true; + warn( + "the host's pi-ai module or its libraries are unavailable; the plane routes stay pi-ai's" + ); + } + }; + + /** + * The served pi-ai changed (it was disposed, or stopped declaring a plane): + * what was built from the previous one is dropped — its handle + * released, so its planes are no longer served from a row that is gone, + * and nothing of it reaches the Settings graft — and a new served row that + * already holds a plane is restarted once it settles, as the mount does. + */ + const handOver = (served: HostFiber | undefined): boolean => { + const { handle } = state; + state.served = served; + state.handle = undefined; + state.facts = undefined; + state.entriesKey = undefined; + state.profiles = new Map(); + state.effortNames = new Map(); + state.normalized = {}; + // pi-ai's module is read from the served row's own loader entry. + state.modules = undefined; + state.adapter = undefined; + try { + handle?.(); + } catch (error) { + warn("the plane routes did not release", error); + } + if (served === undefined || resolvedPlanes(served).length === 0) { + return false; + } + void (async () => { + try { + await restartHolder(served); + } catch (error) { + warn("pi-ai did not restart to hand the plane routes over", error); + } + })(); + return true; + }; + + /** Say once per row that another pi-ai row serves a plane itself. */ + const reportOtherHolders = (served: HostFiber | undefined): void => { + for (const fiber of piAiFibers(ctx)) { + if ( + fiber === served || + fiber.uid === null || + state.warnedHolders.has(fiber) + ) { + continue; + } + const held = resolvedPlanes(fiber); + if (held.length > 0) { + state.warnedHolders.add(fiber); + warn( + `the pi-ai row "${fiber.entry?.options?.id ?? PI_AI_RUNTIME}" serves ${held.join(", ")} itself: only one pi-ai row's planes are the plugin's` + ); + } + } + }; + + /** Restart pi-ai so it takes the planes back, reporting a failure. */ + const restartToHandBack = async (pi: HostFiber): Promise => { + try { + await restartOnceSettled(pi); + } catch (error) { + warn("pi-ai did not restart to take the plane routes back", error); + } + }; + + /** + * Hand the planes back to pi-ai: release the handle and go inert with no + * await between, so pi-ai's restart never meets the plugin's registration + * (DUPLICATE_ADAPTER would fail pi-ai and every route it holds), then + * restart it once it settles. + */ + const handBack = (pi: HostFiber | undefined): void => { + const { handle } = state; + state.handle = undefined; + state.facts = undefined; + state.active = false; + try { + handle?.(); + } catch (error) { + warn("the plane routes did not release", error); + } + if (pi !== undefined && pi.uid !== null && pi.state !== FAILED) { + void restartToHandBack(pi); + } + }; + + const reconcile = async (): Promise => { + state.scheduled = false; + if (!state.active || !state.ready || state.llm === undefined) { + return; + } + const served = servedRow(); + if (served !== state.served && handOver(served)) { + // The new served row's restart brings us back through the registry. + return; + } + reportOtherHolders(served); + const pi = livePiAi(); + if (pi === undefined) { + // pi-ai is gone or FAILED: nothing is claimed. + state.entriesKey = undefined; + state.profiles = new Map(); + state.normalized = {}; + register(); + return; + } + if (resolvedPlanes(pi).length > 0) { + // pi-ai still holds a plane it resolved before the listener existed; + // the mount's or the handover's restart moves it, and its registry + // change brings us back. + return; + } + if (state.pendingDuplicate) { + state.pendingDuplicate = false; + try { + await pi.await?.(); + } catch { + // A FAILED pi-ai is read as such below. + } + } + const modules = state.modules ?? (await loadHostModules(ctx, pi)); + if (!state.active || state.llm === undefined) { + return; + } + if (modules === undefined) { + // The module lookup does not recover within a live row, so stand down + // whether or not a plane is declared: pi-ai validates and serves a plane + // a later save adds, rather than the claim refusing it for good. With + // nothing declared the handback restarts nothing. + warnUnavailable(); + handBack(pi); + return; + } + state.modules = modules; + state.validationModules = modules; + try { + rebuild(pi, modules); + } catch (error) { + warn("the plane profiles did not build", error); + return; + } + register(); + }; + + const reconcileReported = async (): Promise => { + try { + await reconcile(); + } catch (error) { + warn("the plane routes did not reconcile", error); + } + }; + + const schedule = (): void => { + if (state.scheduled || !state.active) { + return; + } + state.scheduled = true; + queueMicrotask(() => { + void reconcileReported(); + }); + }; + + /** Validate an editor candidate's changed plane entries, strictly. */ + const validate = (fiber: HostFiber, raw: unknown): void => { + const previous = planeEntriesOf(fiber._config); + const candidate = planeEntriesOf(raw); + for (const plane of PLANE_ROUTES) { + if ( + !Object.hasOwn(candidate, plane) || + JSON.stringify(candidate[plane]) === JSON.stringify(previous[plane]) + ) { + continue; + } + // Refused only before the host's modules ever loaded (at boot, before + // the mount's preload settles), never in the window after a handover + // or a partial dispose. + const modules = state.modules ?? state.validationModules; + if (modules === undefined) { + throw new Error( + `llm-pi-ai: provider "${plane}" cannot be validated yet: the plugin has not loaded the host's pi-ai module; try the save again` + ); + } + const { interpolate } = modules.libraries; + const result = planeProfileFor({ + buildProvider: buildPlaneProvider(modules.libraries), + catalog: + plane === "opencode-go" ? getLiveGoCatalog() : getLiveZenCatalog(), + endpoint: config.endpoint, + entry: candidate[plane], + familyApi: + plane === "opencode-go" + ? OPENCODE_FAMILY_API.go + : OPENCODE_FAMILY_API.zen, + ...(interpolate === undefined + ? {} + : { interpolate: (value: unknown) => interpolate(fiber.ctx, value) }), + normalize: (entry: unknown) => { + const resolved: unknown = modules.piAi.Config({ + providers: { [plane]: entry }, + }); + const providers: unknown = isRecord(resolved) + ? resolved.providers + : undefined; + const value: unknown = + isRecord(providers) && isFunctionLike(providers.get) + ? Reflect.apply(providers.get, providers, []) + : providers; + return isRecord(value) ? value[plane] : undefined; + }, + piModels: modules.libraries.builtinModels(plane), + resolveRetryPolicy: modules.libraries.resolveRetryPolicy, + route: plane, + }); + const failure = result.profile.catalogError; + if (failure !== undefined) { + throw new Error(failure); + } + } + }; + + /** The listener's body, with the fiber cordis bound as `this` passed in. */ + const claim = ( + fiber: unknown, + raw: unknown, + next: () => unknown + ): unknown => { + if ( + !state.active || + state.reentrant || + !isPiAi(fiber) || + fiber !== servedRow() + ) { + return next(); + } + const commit = raw === fiber._config; + if (!hasPlaneEntries(raw)) { + if (commit) { + schedule(); + } + return next(); + } + const copy = withoutPlaneProviders(raw); + const host: unknown = fiber.ctx; + let resolved: unknown; + state.reentrant = true; + try { + resolved = + isRecord(host) && isFunctionLike(host.waterfall) + ? Reflect.apply(host.waterfall, host, [ + fiber, + "internal/config", + copy, + () => copy, + ]) + : copy; + } finally { + state.reentrant = false; + } + if (commit) { + schedule(); + return resolved; + } + validate(fiber, raw); + return resolved; + }; + + function claimListener( + this: unknown, + raw: unknown, + next: () => unknown + ): unknown { + return claim(this, raw, next); + } + + const teardown = (): Promise | undefined => { + const { handle } = state; + state.handle = undefined; + state.active = false; + try { + handle?.(); + } catch (error) { + warn("the plane routes did not release", error); + } + images.dispose(); + // Before the reload return: the next activation subscribes its own, and a + // listener left here would keep this activation's state alive for good. + stopCatalog(); + const own: unknown = Reflect.get(ctx, "fiber"); + if (isRecord(own) && own.uid !== null) { + // A reload of the plugin's own row: the next activation claims again. + return undefined; + } + const pi = livePiAi(); + if ( + pi === undefined || + pi.parent?.fiber?.state === UNLOADING || + !hasPlaneEntries(pi._config) + ) { + return undefined; + } + // ACTIVE or still LOADING: either way it resolved through the listener + // while it was active, so it is restarted once it settles. + return (async () => { + try { + await restartOnceSettled(pi); + const llm: unknown = ctx.get?.("llm"); + const listed: unknown = + isRecord(llm) && isFunctionLike(llm.listProviders) + ? Reflect.apply(llm.listProviders, llm, []) + : []; + const ids = new Set( + (Array.isArray(listed) ? listed : []).map((row: unknown) => + isRecord(row) ? row.id : undefined + ) + ); + const declared = Object.keys(planeEntriesOf(pi._config)); + const lost = declared.filter((plane) => !ids.has(plane)); + if (lost.length > 0) { + warn( + `pi-ai did not take back ${lost.join(", ")}: their entries may hold ids its catalog cannot serve` + ); + } + } catch (error) { + warn("pi-ai did not take the plane routes back", error); + } + })(); + }; + + // A catalog refresh that lands re-runs the reconcile, whose memo key holds + // the catalog's generation. + const stopCatalog = onCatalogChange(schedule); + // The first effect `apply` registers, so it is the last to run. + ctx.effect?.(() => teardown, "dsh-opencode-patch.route-claim"); + ctx.on?.("internal/config", claimListener, { prepend: true }); + ctx.on?.("llm/adapters-updated", schedule, { global: true }); + ctx.on?.( + "loader/partial-dispose", + (entry: unknown) => { + if (isRecord(entry) && isPiAi(entry.fiber)) { + state.modules = undefined; + schedule(); + } + }, + { global: true } + ); + ctx.inject?.(["llm"], (scope: unknown) => { + if (!isRecord(scope)) { + return; + } + const { llm } = scope; + state.llm = isRecord(llm) ? llm : undefined; + if (isFunctionLike(scope.effect)) { + Reflect.apply(scope.effect, scope, [ + () => () => { + state.llm = undefined; + state.handle = undefined; + state.facts = undefined; + }, + "dsh-opencode-patch.route-claim: llm", + ]); + } + schedule(); + }); + + const mounted = (async () => { + try { + // Preload the libraries, so the first commit run does not wait on them. + state.modules = await loadHostModules(ctx, livePiAi()); + state.validationModules = state.modules; + const served = servedRow(); + state.served = served; + if (served !== undefined && resolvedPlanes(served).length > 0) { + if (state.modules === undefined) { + // Nothing could serve the planes: stand down without a restart, so + // pi-ai keeps them and restarts zero times rather than twice. The + // handback restarts only a row that settles WITHOUT its planes. + warnUnavailable(); + handBack(served); + return; + } + await restartHolder(served); + } + } catch (error) { + warn("the plane routes did not mount", error); + } finally { + state.ready = true; + schedule(); + } + })(); + + return { + committedEntries: () => (state.active ? state.normalized : {}), + mounted, + piAiNamespace: () => livePiAi()?.entry?.options?.id ?? PI_AI_RUNTIME, + }; +}; diff --git a/src/identity.ts b/src/identity.ts index 6dee1a74..ac37ca34 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -23,8 +23,15 @@ export const LEGACY_NAME = "dsh-opencode"; /** Pre-rename npm package; rows using it get a rename notice. */ export const LEGACY_PKG = "@viztor/dsh-opencode"; -/** Host injections: the plugin needs the `llm` stream lifecycle. */ -export const inject = ["llm"]; +/** + * Host injections: none at the root. + * + * `llm` and `settings` are reached through `ctx.inject` child scopes, so the + * `internal/config` listener that keeps pi-ai off the plane routes, and the + * stream and fetch hooks, attach before `llm` exists — which is what lets the + * plugin claim the planes whichever of it and pi-ai applies first. + */ +export const inject: string[] = []; /** * The OpenCode CLI version this plugin claims to be. diff --git a/src/index.ts b/src/index.ts index a6a663f9..a6d6adc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,8 +17,6 @@ export { inject, name } from "./identity.ts"; export { apply } from "./lifecycle.ts"; export { Config, - type ModelOverrideConfig, - type ModelRouteConfig, type PluginConfig, type ResolvedPluginConfig, } from "./config.ts"; diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 5e7214ff..816c943e 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -6,6 +6,8 @@ * * | installer | side effect | * | ------------------------ | --------------------------------------------- | + * | {@link installRouteClaim} | own the `opencode`/`opencode-go` routes (D13) | + * | {@link installDescribeGraft} | keep Settings showing the planes (D18) | * | {@link logRenameNotice} | one-time notice for legacy rows | * | {@link installUsageService} | register the Go quota service + remotes | * | {@link installFetchPatch} | wrap `globalThis.fetch` (returns `false` if none) | @@ -20,7 +22,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { - type ModelOverrideConfig, type PluginConfig, type ResolvedPluginConfig, resolveConfig, @@ -29,29 +30,24 @@ import { type CordisContext, readEntryOptions } from "./cordis-context.ts"; import { recordDebug } from "./debug.ts"; import { patchFetch } from "./fetch-patch.ts"; import { resolveGoBaseURL } from "./go-discovery.ts"; +import { installDescribeGraft } from "./host/describe-graft.ts"; +import { installRouteClaim } from "./host/route-claim.ts"; import { LEGACY_NAME, LEGACY_PKG, name } from "./identity.ts"; -import { getCapturedApiKey, isPlaceholderApiKey } from "./key-capture.ts"; import { type CatalogModelSpec, getLiveGoCatalog, getLiveZenCatalog, sanitizeModalities, } from "./models-catalog.ts"; -import { - decorateModelDiscovery, - decorateModelInfo, - decoratePlaneAdapters, - hideResponsesRoute, -} from "./models-discovery.ts"; +import { decorateModelDiscovery } from "./models-discovery.ts"; import { STORE_NAMESPACE } from "./opencode-store-contract.ts"; import { OpencodeStoreService } from "./opencode-store-service.ts"; import { ModelPurgeService } from "./purge-service.ts"; -import { registerResponsesProvider } from "./responses-provider.ts"; -import { isServableSdk } from "./responses-routes.ts"; import { createStreamHook } from "./stream-hook.ts"; import type { ActiveTurnState } from "./turn-store.ts"; import { GoUsageService } from "./usage.ts"; -import { isFetchFunction, isRecord } from "./utils/guards.ts"; +import { isFetchFunction, isFunctionLike, isRecord } from "./utils/guards.ts"; +import { isServableSdk } from "./utils/protocols.ts"; /** * One catalog row in the shape DSH's model-discovery surface expects. @@ -59,31 +55,16 @@ import { isFetchFunction, isRecord } from "./utils/guards.ts"; * Models whose SDK no route serves are dropped: offering them would be offering * a model that cannot work, and the user has no way to tell that from the row. */ -const toDiscovered = ( - specs: readonly CatalogModelSpec[], - maxContextWindow?: number, - modelOverrides?: Readonly> -) => +const toDiscovered = (specs: readonly CatalogModelSpec[]) => specs .filter((m) => isServableSdk(m.provider_npm)) - .map((m) => { - const override = modelOverrides?.[m.id]; - const baseContext = override?.contextWindow ?? m.context_window; - const contextWindow = - typeof maxContextWindow === "number" && maxContextWindow > 0 - ? Math.min(baseContext, maxContextWindow) - : baseContext; - return { - contextWindow, - id: m.id, - inputModalities: - override?.inputModalities === undefined - ? sanitizeModalities(m.input_modalities) - : [...override.inputModalities], - maxTokens: override?.maxTokens ?? m.max_output_tokens, - name: override?.name ?? m.name, - }; - }); + .map((m) => ({ + contextWindow: m.context_window, + id: m.id, + inputModalities: sanitizeModalities(m.input_modalities), + maxTokens: m.max_output_tokens, + name: m.name, + })); /** Tell a row still configured under the pre-rename id/package to update. */ const logRenameNotice = (ctx: CordisContext): void => { @@ -330,7 +311,7 @@ const installStoreService = (ctx: CordisContext): void => { /** * Wrap `globalThis.fetch` so claimed OpenCode traffic carries session affinity - * and the gateway headers, restoring both on dispose. + * and the gateway headers, restoring it on dispose. * * @returns `false` when there is no fetch to wrap. The caller then stops: the * stream hook and discovery must not attach, or turns would run without the @@ -377,37 +358,7 @@ const installFetchPatch = ( ", " )}]` ); - // Decorate discovery in the same effect so disabling the plugin restores - // both the gateway fetch behavior and the Host discovery method. - const stopDiscoveryDecoration = decorateModelDiscovery(ctx, config); - // And the model-info decoration, which is what the picker's Effort row - // reads. Same effect and same reason: it replaces a Host method, so it has - // to live on this fiber to be undone on reload rather than stacked. - const stopModelInfoDecoration = decorateModelInfo(ctx, config); - // And the adapters themselves, which is where dispatch reads a model from. - // The picker's answer and the dispatched call must come from one place, and - // this is the half that `resolveModelInfo` cannot reach. - const stopAdapterDecoration = decoratePlaneAdapters(ctx.llm, config); - // Same reasoning for the listing patch: it replaces Host methods, so it must - // be tied to this fiber. Called OUTSIDE an effect it would never be undone, - // and every live reload would stack another wrapper. Installed before the - // stream hook is used, because the redirect asks it whether the route is - // really registered. - // Told which planes are keyless, so the picker can offer only what such a - // deployment can actually call (the official client's rule). - const stopCatalogHiding = hideResponsesRoute(ctx, config, { - hasCredential: hasPlaneCredential, - }); - // Own the Responses route from here, so the user configures nothing: they - // keep the `opencode` provider and key they already have. Best-effort — a - // route declared in the profile still works if this cannot register. - const releaseResponsesProvider = mountInternalRoutes(ctx, config); return () => { - releaseResponsesProvider(); - stopCatalogHiding?.(); - stopDiscoveryDecoration?.(); - stopModelInfoDecoration?.(); - stopAdapterDecoration?.(); if (globalThis.fetch === patched) { globalThis.fetch = originalFetch; } @@ -458,15 +409,15 @@ const DISCOVERY_NAMESPACES: readonly { * Registered once per root and never withdrawn, like the usage service. The * earlier shape registered on every activation and swallowed the host's * duplicate refusal, so the FIRST activation's settings answered forever: a - * reload that changed `modelOverrides`, `maxContextWindow` or `enrichModels` - * changed nothing the picker saw. Each namespace is registered on its own, so - * one refusal no longer skips the others. + * reload that changed `enrichModels` changed nothing the picker saw. Each + * namespace is registered on its own, so one refusal no longer skips the + * others. */ const installModelDiscovery = ( - ctx: CordisContext, + scope: CordisContext, config: ResolvedPluginConfig ): void => { - const root: CordisContext = ctx.root ?? ctx; + const root: CordisContext = scope.root ?? scope; const registered = discoveryConfigOn.has(root); if (!registered && !config.enrichModels) { return; @@ -475,7 +426,7 @@ const installModelDiscovery = ( if (registered) { return; } - const { llm } = ctx; + const { llm } = scope; if (typeof llm?.registerModelDiscovery !== "function") { return; } @@ -486,18 +437,12 @@ const installModelDiscovery = ( llm.registerModelDiscovery(ns, () => { const settings = current(); return Promise.resolve( - settings.enrichModels - ? toDiscovered( - catalog(), - settings.maxContextWindow, - settings.modelOverrides - ) - : [] + settings.enrichModels ? toDiscovered(catalog()) : [] ); }); } catch (error) { // Non-fatal, and said: a picker missing a plane is otherwise a silence. - ctx.logger?.warn?.( + scope.logger?.warn?.( `[dsh-opencode-patch] model discovery for ${ns} did not register`, error ); @@ -506,115 +451,69 @@ const installModelDiscovery = ( }; /** - * Mount the internal protocol routes for one activation, and own their withdrawal. - * - * The registration is async (it reads `llm-pi-ai` off the loader), so the - * disposer cannot live in a local the effect body fills in later: an effect - * that re-runs before the mount settles would find `undefined`, release nothing, - * and leave the previous mount's routes registered. So this owns the - * WITHDRAWAL, not the mount: a mount that completes after release is withdrawn - * on the spot, and one that completes before it is withdrawn by the release. - * - * A REJECTED mount is reported and contained. It used to escape as an unhandled - * rejection from a `void` IIFE — which Node's default mode turns into a process - * exit — whenever the Google bridge failed to register. - * - * @param ctx - the host context. - * @param config - the resolved settings the mount reads. - * @param register - the mount itself; a parameter so its failure is testable. - * @returns the release, safe to call at any point and more than once. + * Discovery for one `llm` scope: the catalog namespaces, and the wrapper that + * merges the catalog into a discovery answer, tied to the scope so an `llm` + * restart or a disable undoes it. */ -export const mountInternalRoutes = ( +const installDiscovery = ( ctx: CordisContext, - config: ResolvedPluginConfig, - register: typeof registerResponsesProvider = registerResponsesProvider -): (() => void) => { - let stop: (() => void) | undefined; - let released = false; - void (async () => { - let next: (() => void) | undefined; - try { - next = await register(ctx, config); - } catch (error) { - ctx.logger?.warn?.( - "[dsh-opencode-patch] the internal routes did not mount; routes declared in the profile are unaffected", - error - ); + config: ResolvedPluginConfig +): void => { + ctx.inject?.(["llm"], (scope: unknown) => { + if (!isRecord(scope)) { return; } - if (released) { - next?.(); - return; + const llmScope = scope as CordisContext; + installModelDiscovery(llmScope, config); + const stop = decorateModelDiscovery(llmScope, config); + if (isFunctionLike(llmScope.effect)) { + llmScope.effect(() => () => stop?.(), "dsh-opencode-patch.discovery"); } - stop = next; - })(); - return () => { - released = true; - stop?.(); - stop = undefined; - }; -}; - -/** - * Whether this plane holds a credential the gateway would accept. - * - * Deliberately synchronous and deliberately generous: a declared env key or a - * key captured from a live request. The - * SQLite store is not consulted because it is opt-in and off by default, and - * calling a deployment "keyless" when it does hold a credential would hide models - * it can actually use. - * - * @param plane - the gateway plane. - * @returns whether any credential is known for it. - */ -const hasPlaneCredential = (plane: string): boolean => { - const declared = - plane === "opencode-go" - ? process.env.OPENCODE_GO_API_KEY - : process.env.OPENCODE_API_KEY; - if ( - typeof declared === "string" && - declared.length > 0 && - !isPlaceholderApiKey(declared) - ) { - return true; - } - return getCapturedApiKey(plane) !== undefined; + }); }; /** - * Plugin entry: register the quota service, patch `fetch` for OpenCode - * traffic, and capture turn state for configured providers. + * Plugin entry: claim the plane routes, register the quota service, patch + * `fetch` for OpenCode traffic, and capture turn state for configured + * providers. * * Order matters: - * 1. the rename notice, so it is the first thing a legacy row logs; - * 2. the quota service, before the fetch patch, so a composition without - * `globalThis.fetch` still gets a working meter; - * 3. the fetch patch, which bails out (warning) when there is nothing to - * wrap — `on` must not be attached in that case, or turns would run + * 1. the route claim, first, so its teardown is the first effect and runs + * last, and its `internal/config` listener is in place before any pi-ai + * config run this activation can see; + * 2. the Settings graft, which reads the claim's committed entries; + * 3. the rename notice and the services, before the fetch patch, so a + * composition without `globalThis.fetch` still gets a working meter; + * 4. the fetch patch, which bails out (warning) when there is nothing to + * wrap — the stream hook must not be attached then, or turns would run * without session affinity; - * 4. the stream hook and model discovery, which only make sense once the + * 5. the stream hook and model discovery, which only make sense once the * request path is patched. * + * The returned promise is the claim's mount: cordis waits for it, which is + * what orders a pi-ai restart against the rest of the boot (F17). It never + * rejects. + * * @param ctx - the Cordis host context. * @param rawConfig - the row config, if any. */ -export const apply = ( +export const apply = async ( ctx: CordisContext, rawConfig: PluginConfig = {} -): void => { +): Promise => { const config = resolveConfig(rawConfig); const als = new AsyncLocalStorage(); + const claim = installRouteClaim(ctx, config); + installDescribeGraft(ctx, claim); logRenameNotice(ctx); installUsageService(ctx, config); installPurgeService(ctx); installStoreService(ctx); - if (!installFetchPatch(ctx, config, als)) { - return; + if (installFetchPatch(ctx, config, als)) { + installStreamHook(ctx, config, als); + installDiscovery(ctx, config); } - - installStreamHook(ctx, config, als); - installModelDiscovery(ctx, config); + await claim.mounted; }; diff --git a/src/models-catalog.ts b/src/models-catalog.ts index 5a263acb..db934519 100644 --- a/src/models-catalog.ts +++ b/src/models-catalog.ts @@ -128,6 +128,55 @@ const deprecatedGoIds = new Set(); const deprecatedZenIds = new Set(); let lastRefreshedAt = 0; +/** + * Bumped whenever either active map is replaced, so a reader that derives + * something from the catalog (the plane profiles) can tell its derivation is + * stale without comparing maps. + */ +let generation = 0; +/** Called after a refresh replaced a map; see {@link onCatalogChange}. */ +const changeListeners = new Set<() => void>(); + +/** + * The catalog's generation: a number that changes whenever the live catalog + * does, and only then. + */ +export const catalogGeneration = (): number => generation; + +/** + * Be told when a refresh lands new catalog data. The bundled shim is what a + * process starts from, so the first reader of the catalog sees it and only + * triggers the refresh; a listener is how that reader learns the refresh + * landed. A listener that throws is ignored: the refresh stays a success. + * + * @param listener - called after the maps are replaced. + * @returns the unsubscribe. + */ +export const onCatalogChange = (listener: () => void): (() => void) => { + changeListeners.add(listener); + return () => { + changeListeners.delete(listener); + }; +}; + +/** + * How many change listeners are subscribed: the probe that shows an + * unsubscribe happened, since a stale listener that does nothing is otherwise + * indistinguishable from none. + */ +export const catalogListenerCount = (): number => changeListeners.size; + +/** Bump the generation and tell every listener. */ +const catalogChanged = (): void => { + generation += 1; + for (const listener of changeListeners) { + try { + listener(); + } catch { + // A listener's failure is its own; the catalog changed either way. + } + } +}; /** * Whether the MOST RECENT refresh attempt read a usable payload. * @@ -162,6 +211,7 @@ export const resetCatalogState = (): void => { lastRefreshedAt = 0; lastRefreshSucceeded = false; refreshPromise = undefined; + generation += 1; }; export interface ParsedCatalogs { @@ -392,8 +442,8 @@ export type CatalogPlane = "go" | "zen"; /** * The plane a route's models come from. * - * `opencode-go` is the subscription plane; everything else this plugin knows - * about (`opencode`, and the internal routes derived from it) is Zen. + * `opencode-go` is the subscription plane, and so is an `opencode-go-*` relay + * route; everything else this plugin knows about (`opencode`) is Zen. */ export const catalogPlaneForRoute = (route: unknown): CatalogPlane => route === "opencode-go" || @@ -574,6 +624,7 @@ export const refreshCatalog = ( if (parsed.go.length > 0 || parsed.zen.length > 0) { lastRefreshedAt = Date.now(); lastRefreshSucceeded = true; + catalogChanged(); } } } catch { diff --git a/src/models-discovery.ts b/src/models-discovery.ts index 6e114369..4123e2de 100644 --- a/src/models-discovery.ts +++ b/src/models-discovery.ts @@ -1,13 +1,14 @@ /** * Provider-aware decoration for DSH model discovery. * - * The settings surface asks the adapter that owns a route—`llm-pi-ai` for - * `opencode` and `opencode-go`—for candidates. When that adapter already - * knows the route, it answers from its installed catalog without calling the + * The settings surface asks the adapter that owns a route for candidates — + * this plugin's plane adapter for `opencode` and `opencode-go`, which answers + * the way `llm-pi-ai` does, from pi's installed catalog without calling the * gateway, so response enrichment never runs. Decorating its returned - * candidates is therefore the only in-plugin way to add a catalog model the - * installed catalog omits, or to remove a model OpenCode CLI retired, from a - * “fetch available models” answer. + * candidates is therefore the in-plugin way to fill a capacity the installed + * catalog omits, or to remove a model OpenCode CLI retired, from a “fetch + * available models” answer. (P3 replaces this wrapper with a discovery + * answer of the plugin's own.) * * The decorator only rewrites results for an explicitly claimed OpenCode * provider route. Nothing is stored: as with the underlying discovery call, @@ -18,31 +19,13 @@ import type { ResolvedPluginConfig } from "./config.ts"; import type { CordisContext } from "./cordis-context.ts"; -import { declaredProviderModels } from "./go-discovery.ts"; -import { patchMethod, type UnpatchedMethod } from "./host/patch-method.ts"; +import { patchMethod } from "./host/patch-method.ts"; import { - catalogPlaneForRoute, - type CatalogPlane, findModelSpecOn, isDeprecatedModelOn, sanitizeModalities, type CatalogProvider, } from "./models-catalog.ts"; -import { - baseForProtocol, - LEGACY_PLANE_API, - protocolFor, - thinkingLevelMapFor, -} from "./pi-provider.ts"; -import { - GO_GOOGLE_INTERNAL_ROUTE, - internalRouteFor, - isInternalRoute, - isServableSdk, - PLANES, - protocolFromModelId, - UNSERVED_PLANE_SDKS, -} from "./responses-routes.ts"; import { isRecord } from "./utils/guards.ts"; import { isGoPlaneUrl } from "./utils/request-shape.ts"; @@ -53,9 +36,6 @@ import { isGoPlaneUrl } from "./utils/request-shape.ts"; */ const PATCH_SITE = { discovery: "dsh-opencode-patch/model-discovery", - listing: "dsh-opencode-patch/listing", - modelInfo: "dsh-opencode-patch/model-info", - planeAdapter: "dsh-opencode-patch/plane-adapter", } as const; export interface DiscoveryCandidate { @@ -210,334 +190,6 @@ export const mergeDiscoveredModels = ( return merged; }; -/** - * The Host's UNFILTERED provider listing, captured when the hiding patch is - * installed. The re-dispatch must consult this one: the patched listing - * deliberately omits the route, so checking the patched method would make the - * redirect never fire. - */ -/** - * Kept WITH its receiver. A method read off the service and called detached - * runs with `this === undefined`, and the LLM registry reads its own state - * through `this` — so the detached call surfaces as - * "Cannot read properties of undefined (reading 'adapters')", which names the - * registry's internals rather than the call that broke the receiver. - */ -let registeredRoutes: { list: () => unknown; receiver: unknown } | undefined; - -/** - * Whether a route the plugin owns is really registered on the Host. - * - * The redirect must consult the UNFILTERED registry: the patched listing - * deliberately omits every internal route, so asking it would always answer "no" - * and the re-dispatch would never fire. - * - * @param routeId - the internal route to test for. - * @returns true when the unfiltered registry still carries the route. - */ -export const isRouteRegistered = (routeId: string): boolean => { - if (registeredRoutes === undefined) { - return false; - } - const { receiver } = registeredRoutes; - if ( - isRecord(receiver) && - "adapters" in receiver && - receiver.adapters instanceof Map && - receiver.adapters.has(routeId) - ) { - return true; - } - const routes = Reflect.apply( - registeredRoutes.list, - registeredRoutes.receiver, - [] - ); - return ( - Array.isArray(routes) && - routes.some((route) => isRecord(route) && route.id === routeId) - ); -}; - -/** Drop every internal route from a Host listing, leaving other entries alone. */ -const withoutRoute = (value: unknown, key: "id" | "provider"): unknown => - Array.isArray(value) - ? value.filter((entry) => !(isRecord(entry) && isInternalRoute(entry[key]))) - : value; - -/** The configurable directory, without the internal routes. */ -const wrapListConfigurableProviders = (unpatched: UnpatchedMethod) => - function listConfigurableProviders(this: unknown): unknown { - return withoutRoute(Reflect.apply(unpatched, this, []), "provider"); - }; - -/** - * Whether a model can be served, judged by the SDK the PLANE IN HAND declares. - * - * The plane matters: `qwen3.8-max`, `minimax-m2.7` and `minimax-m3` are - * `@ai-sdk/anthropic` on Go and the default (completions) on Zen, so a Go-first - * lookup filters a Zen listing by a Go fact. Same seam as the redirect's — see - * `findModelSpecOn`. - */ -/** - * Whether a model may be offered on a route. - * - * Exported for its own test: it is the one place I1's second clause lives, and the - * interesting case is a model no catalog describes. - */ -export const isServableModel = (id: unknown, route: string): boolean => { - if (typeof id !== "string") { - return true; - } - // A free-tier id is OFFERED, and not because it is served from here - measured - // since, and it is not: an API key, the account token, the token with its org, - // every header the client sends (the message id included), both endpoints and - // no credential at all each answer `403 FreeTierError`, while a paid model on - // the same request answers `200`. A round of this reasoning claimed a signed-in - // deployment would be served; it is not, and the run was made. What the console - // source shows is that the refusal is not this request's shape at all: for a - // model whose entry allows anonymous use, the console forwards the request to - // the inference service keyless and does not refuse it itself, so the gate is - // the vendor's service to open or close. Offering the ids is the decision that - // follows - hiding would remove a row the vendor may serve, and the refusal is - // legible now that it is reported as the model's own answer rather than as a - // bad key. - // - // The refusal is not silent when it happens: `fetch-patch.ts` maps it to `400 - // free_tier_restricted` in the gateway's own words, and `stream-hook.ts` - // reports a model this plugin cannot serve, once per pair - that report is what - // a reader should be given instead of an empty picker. - const plane = catalogPlaneForRoute(route); - const spec = findModelSpecOn(plane, id); - const sdk = spec?.provider_npm; - if (sdk !== undefined) { - if (UNSERVED_PLANE_SDKS.has(`${route} + ${sdk}`)) { - return false; - } - return isServableSdk(sdk); - } - // A model the catalog does not describe: `undefined` is the completions default, - // which is right for a model that omits its SDK and wrong for one nobody has - // described. So the id is asked, and a family whose protocol this plane has no - // route for is not offered - I1's second clause, which until now held only for - // models the catalog carries. `internalRouteFor` answers exactly this: a route - // when one exists, and `undefined` when the model stays where it is. - return ( - protocolFromModelId(id) === undefined || - internalRouteFor(route, id) !== undefined - ); -}; - -/** - * Keep the internal routes out of every listing a user sees. - * - * Three surfaces enumerate providers, and the route has to be absent from all - * of them or it shows up as a provider the user is invited to configure: - * - * - `buildModelCatalog` (the model picker) turns every registered route into a - * group and drops the groups whose model list is empty, so reporting no models - * removes it there. - * - `joinProviderDirectory` (Settings → Models) maps `listConfigurableProviders` - * and then pushes a row for **every remaining registered provider** — so - * filtering only the configurable directory would not hide it. - * - * **`listModels` IS patched, and the note that used to sit here saying it was - * not is why the picker lost most of the catalog.** The old text read: - * - * "`listModels` is deliberately NOT patched. Every consumer of it in the Host - * iterates `listProviders()` first … so once the registry omits the route, - * nothing reaches its model list." - * - * The premise is true and the conclusion does not follow. Hiding the internal - * routes is exactly why their models must be JOINED into the parent route's list: - * `buildModelCatalog` is `listProviders()` × `listModels(id)`, each internal route - * carries only its own protocol's models, and hiding it therefore makes those - * models unreachable rather than merely unlisted. Measured: 27 models in the - * picker before the join, 88 after. - * - * The patch now does two things: it empties an internal route's own list (nothing - * reaches it, but a third patch guarding nothing is cheaper than a surprise), and - * it merges each plane root's list with its plane's catalog. Both are below. - * - * Nothing in dispatch reads these: the adapter registry resolves a route - * internally, and the `llm/stream` hook uses {@link isResponsesRouteRegistered} - * — which reads the ORIGINAL listing, captured here — to decide whether the - * redirect has somewhere to go. - * - * @param ctx - host context carrying the LLM registry. - * @param config - the resolved config, for the ids a deployment withholds. - * @returns the disposer restoring the original methods, or `undefined` when - * there is nothing to patch. - */ -/** - * What the listing patch needs from its caller beyond the configuration. - */ -export interface ListingOptions { - /** - * Whether this plane has a credential the gateway would accept. - * - * Absent means "do not ask": a caller that cannot tell leaves the list exactly - * as the deployment declared it. Only a definite `false` narrows anything. - */ - readonly hasCredential?: (plane: string) => boolean | Promise; -} - -/** The two routes this plugin owns; a deployment's own relay is not one. */ -const isPlaneRoute = (provider: string): boolean => - provider === "opencode" || provider === "opencode-go"; - -/** - * Whether the catalog prices this model above zero, on its own plane. - * - * The official client's keyless rule is exactly this test - * (`packages/opencode/src/provider/provider.ts:231-237`: delete every model whose - * `cost.input` is not 0 when no credential is present), so a keyless deployment - * is offered the free ids and nothing it cannot pay for. - * - * @param plane - the catalog plane the route belongs to. - * @param id - the model id. - * @returns whether the model costs anything to call. - */ -const costsMoney = (plane: CatalogPlane, id: string): boolean => { - const cost = findModelSpecOn(plane, id)?.cost; - return cost !== undefined && cost.input > 0; -}; - -/** The listing methods the patch wraps, in the order it installs them. */ -const LISTING_KEYS = [ - "listProviders", - "listConfigurableProviders", - "listModels", -] as const; - -export const hideResponsesRoute = ( - ctx: CordisContext, - config: ResolvedPluginConfig, - options: ListingOptions = {} -): (() => void) | undefined => { - const { llm } = ctx; - if (llm === undefined) { - return undefined; - } - // Today's install order, and only the methods the host has. - const keys = LISTING_KEYS.filter((key) => typeof llm[key] === "function"); - if (keys.length === 0) { - return undefined; - } - - const wrapListProviders = (unpatched: UnpatchedMethod) => { - // Captured as the wrapper is built, which is the TRUE unfiltered listing: - // a reload's second install is handed the host's own method, not the first - // install's filtered one. - registeredRoutes = { list: unpatched, receiver: llm }; - return function listProviders(this: unknown): unknown { - return withoutRoute(Reflect.apply(unpatched, this, []), "id"); - }; - }; - // **The deployment's model list is the whole list.** This route's `models:` - // array is a decision the operator made, and the picker must show it and - // nothing else. - // - // An earlier revision merged the plane's whole catalog in here, so that - // models living on the hidden protocol routes would be reachable. That was - // wrong, and the way it failed is worth keeping: the join was invisible on - // screen (nobody can tell 27 rows from 88 unless they counted the config), it - // overrode every explicit `models:` list, and it put models the operator never - // offered in front of them — which is how a request for a model the account - // cannot use became the reported bug. A route's list is not ours to widen. - // - // What remains is a filter, and only that: a model the plane cannot serve is - // dropped, because offering one produces a failure with nothing in the row to - // explain it. `internalRouteFor` still redirects a DECLARED model whose - // protocol differs, so a Responses / Messages / Google id the operator lists - // keeps working — it just has to be listed. - const wrapListModels = (unpatched: UnpatchedMethod) => - async function listModels( - this: unknown, - provider: string - ): Promise { - if (isInternalRoute(provider)) { - return []; - } - const models = await Reflect.apply(unpatched, this, [provider]); - // **Only the routes this plugin claims.** A provider the deployment did - // not hand it — a direct `anthropic` or `openai` route — is answered by its - // own adapter and nothing else: the servability rule below reads OUR - // catalog, and against a foreign route it dropped real rows (a new Claude - // or GPT id has no OpenCode route, so it read as "unservable"). - if (!Array.isArray(models) || !config.providers.has(provider)) { - return models; - } - // **An empty answer from a route that DECLARES models is a credential - // fact, not a catalogue fact.** `dsh-llm-pi-ai` resolves the route's key - // before listing and answers `[]` when it cannot — which at boot is the - // ordinary case, and it emptied the picker for a route that declares - // nineteen models. The declaration is in the loader config and needs no - // credential, so it answers here. - // - // Only for the plane routes, and only when the adapter answered nothing: - // an adapter that answered keeps its own list, unchanged. - if (models.length === 0 && isPlaneRoute(provider)) { - const declared = declaredProviderModels(ctx, provider); - if (declared !== undefined && declared.length > 0) { - return declared.map((entry) => ({ - id: entry.id, - name: entry.name ?? entry.id, - provider, - })); - } - } - const servable = models.filter( - (model) => !isRecord(model) || isServableModel(model.id, provider) - ); - // **Keyless deployments are offered only what they can actually call.** - // The official client deletes every model whose price is non-zero when no - // credential is present; without this the picker offers paid rows that - // answer 401 with nothing in the row to explain it. - if (options.hasCredential === undefined || !isPlaneRoute(provider)) { - return servable; - } - const keyed = await options.hasCredential(provider); - if (keyed) { - return servable; - } - const plane = catalogPlaneForRoute(provider); - return servable.filter((model) => { - if (!isRecord(model)) { - return true; - } - const { id } = model; - return typeof id !== "string" || !costsMoney(plane, id); - }); - }; - - const factories = { - listConfigurableProviders: wrapListConfigurableProviders, - listModels: wrapListModels, - listProviders: wrapListProviders, - }; - - const stops: (() => void)[] = []; - for (const key of keys) { - const stop = patchMethod(llm, key, factories[key], { - id: PATCH_SITE.listing, - }); - if (stop === undefined) { - // All or nothing: a host that refuses one listing keeps all three. - for (const undo of stops.toReversed()) { - undo(); - } - return undefined; - } - stops.push(stop); - } - return () => { - for (const undo of stops.toReversed()) { - undo(); - } - }; -}; - /** * Wrap the Host’s model-discovery method so “fetch available models” answers * for claimed OpenCode routes include the canonical catalog’s missing rows @@ -582,674 +234,3 @@ export const decorateModelDiscovery = ( { id: PATCH_SITE.discovery } ); }; - -/** Every pi thinking level, in escalation order, `off` first. */ -export const PI_THINKING_LEVELS: readonly string[] = [ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -]; - -/** - * The reasoning efforts DSH renders for one model, read off the SAME - * thinking-level map the routes dispatch on. - * - * One rule, so a level the composer offers is a level the model answers: a - * level the map pins to `null` is not offered, and every other level is. The - * wire spelling is NOT carried — an adapter's effort `id` is the pi level, and - * `resolveReasoningLevel` matches it by that name before the adapter translates - * it, so re-spelling it here would make the composer offer an effort the - * resolver refuses. - * - * @param map - the model's `thinkingLevelMap`. - * @returns the efforts, in escalation order. - */ -const effortsFromLevelMap = ( - map: Partial> -): { id: string; name: string }[] => - PI_THINKING_LEVELS.filter((level) => map[level] !== null).map((level) => ({ - id: level, - name: - level === "off" - ? "Off" - : `${level.charAt(0).toUpperCase()}${level.slice(1)}`, - })); - -/** - * The vendor's retired plane bases, as they appear in pi's installed catalog and - * in profiles written before the per-family move. - * - * Only these are rewritten. Matching a path rather than an origin keeps a - * deployment's own proxy out of it: `https://gateway.internal/zen/v1` is not this - * vendor's endpoint and is none of our business. - * - * The list is DERIVED from {@link LEGACY_PLANE_API}, not written out here. It was - * written out here, and it carried only the Zen plane — so a profile declaring - * `…/zen/v1` was repaired to the inference family while one declaring - * `…/zen/go/v1`, the older address of the *other* plane, was left alone. Under - * `endpoint: auto` both are "follow the catalog", and the second plane's old - * address was quietly exempt: measured, both families answer 200 on Go, so the - * only thing the omission bought was one plane drifting behind the other. - * - * The bare `…/zen` entry is the alias pi's own installed catalog still ships, - * which the plane table writes with its version segment. - */ -const LEGACY_PLANE_BASES: ReadonlySet = new Set([ - "https://opencode.ai/zen", - ...Object.values(LEGACY_PLANE_API), -]); - -/** Whether a value is a usable token count. */ -const usableWindow = (value: unknown): value is number => - typeof value === "number" && Number.isFinite(value) && value > 0; - -/** - * The context window a model actually gets. - * - * The rule is the repository's own, applied to a number: **a declaration outranks - * an estimate.** The window the deployment wrote wins outright, and the catalog's - * value is the fallback when it wrote none — which matters because the catalog is - * a bundled snapshot of the vendor's published limits and is conservative where - * the vendor is: the free DeepSeek id is listed at 200k while the model serves 1M. - * `Math.min(requested, canonical)` used to clamp that back down and made a stale - * entry unoverridable. - * - * `maxContextWindow` still clamps, because it is the deployment's own explicit - * ceiling rather than our estimate of the model's. - * - * @param requestedWindow - the window the deployment (or the catalog) declared. - * @param maxCeiling - the deployment's ceiling, when it configured one. - * @returns the effective window in tokens, or 0 when nothing usable was given. - */ -export const resolveEffectiveContextWindow = ( - requestedWindow: number, - maxCeiling?: unknown -): number => { - const base = usableWindow(requestedWindow) ? requestedWindow : 0; - return usableWindow(maxCeiling) ? Math.min(base, maxCeiling) : base; -}; - -/** - * Wrap the ADAPTERS behind the plane routes, so a model our catalog carries but - * pi's installed catalog does not is resolvable at dispatch, and takes every - * thinking level the composer offers. - * - * This is the seam the `modelOverrides` attempt missed, and the method that - * matters is `modelOf`. The picker reads the public `ctx.llm.resolveModelInfo`, - * which this plugin decorates, so the composer renders OUR catalog's context and - * levels for every catalogued model. Dispatch (`streamWithSnapshot`) resolves - * the model through the adapter's `modelOf` and validates the saved effort with - * `getSupportedThinkingLevels`; `modelInfo` - and so `resolveModel` and - * `prepareCall` - read the same method, which is why filling the picker's answer - * was never enough. For an id pi's installed catalog does not carry - - * `glm-5.3-flash-free` on Zen, `cat-jam-free` on both planes, both declared in - * the deployment's profile - with no `reasoningEfforts` of its own on the row, - * that model materializes as `reasoning: false` with no level map: the only - * level it takes is `off`, so every level the picker offered came back - * UNSUPPORTED_REASONING_EFFORT. - * - * A `modelOverrides` entry cannot fix this - it only DESCRIBES ids - * `defaults.has(id)`, and `entries` comes from `defaults.values()`, so an id pi - * has never heard of is rejected in strict validation and never served anyway. - * - * The registry keeps its adapters in a public `Map`, so the registration is - * wrapped in place: nothing is re-registered, the user's profile is untouched, and - * the route keeps the adapter it always had. `resolveModel` and `prepareCall` fill - * only what the adapter left BLANK, so an adapter that knows the model keeps its - * own answer. `modelOf` is the other shape - what comes out of it has to ACCEPT - * the levels the composer offers, or a saved effort cannot be dispatched - so it - * fills the levels the model REFUSES and leaves every level it already takes - * alone: nothing is removed, and a level that dispatches today dispatches the - * same way afterwards. - * - * Only the adapters behind the plane ROUTES are wrapped - a foreign adapter in - * the same registry (another provider's) is not this plugin's to replace - and - * the original runs with the adapter as its receiver, because all three are - * class methods that read `this`. - * - * Each (adapter, method) is ONE patch, installed through `patchMethod` - * (`host/patch-method.ts`). pi-ai serves both plane routes from one adapter - * instance, so that patch composes the adapter's plane routes in registry - * order, each fill wrapping the previous one's - the nesting the per-route wraps - * always had, and the one the host characterization pins - and a reload - * replaces the whole composition instead of wrapping it again. - * - * @param llm - the Host's LLM service. - * @param config - the resolved config, for the ids a deployment withholds. - * @returns the disposer restoring the original methods, or `undefined`. - */ -export const decoratePlaneAdapters = ( - llm: CordisContext["llm"], - config: ResolvedPluginConfig -): (() => void) | undefined => { - // The registry's own map. Reached structurally rather than through a typed - // handle because the service exposes it as a field, not a method. - const registry = (llm ?? {}) as { - adapters?: Map }>; - }; - const { adapters } = registry; - if (!(adapters instanceof Map)) { - return undefined; - } - // pi-ai serves both planes from ONE adapter instance, so the routes are - // grouped per adapter, in registry order, and each plane's fill wraps the - // previous plane's: the later route's fill runs outermost and sees what the - // earlier one answered. One patch per (adapter, method) composes them, which - // is what a reload replaces as a whole. - const planeRoutes = new Map, string[]>(); - for (const [route, registration] of adapters) { - const adapter = registration?.adapter; - if (adapter === undefined || adapter === null) { - continue; - } - // Only the adapters behind THIS plugin's plane routes: the fill exists for - // the routes a deployment declares without a `models` list, whose adapter - // falls back to pi's installed catalog. Another provider's adapter is not - // this plugin's to replace. - if (!(route in PLANES)) { - continue; - } - planeRoutes.set(adapter, [...(planeRoutes.get(adapter) ?? []), route]); - } - - const stops: (() => void)[] = []; - for (const [adapter, routes] of planeRoutes) { - for (const method of ["resolveModel", "prepareCall"] as const) { - const stop = patchMethod( - adapter, - method, - (unpatched) => { - // The original with the adapter as its receiver: both methods are - // class methods that read `this` (pi-ai's open with - // `this.current()`), and a detached call fails the provider's whole - // catalog load. - let chain = ( - provider: string, - model: string, - signal?: AbortSignal - ): unknown => - Reflect.apply(unpatched, adapter, [provider, model, signal]); - for (const route of routes) { - // Captured per route: `chain` itself is reassigned below, and a - // fill that called it would call itself. - const inner = chain; - // The plane comes from the ROUTE, so a Go model is filled from the - // Go catalog and a Zen one from Zen's - the same rule - // `findModelSpecOn` applies. - const plane = catalogPlaneForRoute(route); - chain = async ( - provider: string, - model: string, - signal?: AbortSignal - ): Promise => { - // Go's shipped catalog cannot resolve Gemini before the stream hook runs. - // Ask the native bridge during model preparation as well as generation. - if ( - provider === "opencode-go" && - internalRouteFor( - provider, - model, - findModelSpecOn("go", model)?.provider_npm, - config.modelRoutes[model]?.api - ) === GO_GOOGLE_INTERNAL_ROUTE - ) { - const target = adapters.get(GO_GOOGLE_INTERNAL_ROUTE)?.adapter; - const resolve = target?.[method]; - if (target !== undefined && typeof resolve === "function") { - const prepared: unknown = await Reflect.apply( - resolve, - target, - [GO_GOOGLE_INTERNAL_ROUTE, model, signal] - ); - if (isRecord(prepared)) - return method === "prepareCall" && isRecord(prepared.model) - ? { ...prepared, model: { ...prepared.model, provider } } - : { ...prepared, provider }; - return prepared; - } - } - // What the earlier plane's fill answered, or the adapter's own method - // when this is the first plane on the adapter. - const answered: unknown = await inner(provider, model, signal); - const spec = findModelSpecOn(plane, model); - if (spec === undefined) { - return answered; - } - // Only what the adapter left BLANK. An adapter that answers keeps its - // answer, because that is what dispatch will honour. - const override = config.modelOverrides[model]; - const record = isRecord(answered) ? answered : {}; - const existingContext = isRecord(record.context) - ? record.context - : {}; - const requestedWindow = - override?.contextWindow ?? - (typeof existingContext.contextWindow === "number" - ? existingContext.contextWindow - : undefined); - // What the deployment declared outranks the catalog; the catalog is the - // fallback when nothing did. - const declaredWindow = requestedWindow ?? spec.context_window; - const effectiveWindow = - declaredWindow > 0 - ? resolveEffectiveContextWindow( - declaredWindow, - config.maxContextWindow - ) - : undefined; - const modelName = - override?.name ?? - (typeof record.name === "string" ? record.name : spec.name); - const { defaultMaxTokens: recordMaxTokens } = record; - let inputModalities: ("text" | "image")[] = sanitizeModalities( - spec.input_modalities - ); - if (override?.inputModalities !== undefined) { - inputModalities = [...override.inputModalities]; - } else if (Array.isArray(record.inputModalities)) { - inputModalities = sanitizeModalities(record.inputModalities); - } - - let defaultMaxTokens: number | undefined; - if (override?.maxTokens !== undefined) { - defaultMaxTokens = override.maxTokens; - } else if (typeof recordMaxTokens === "number") { - defaultMaxTokens = recordMaxTokens; - } else if (spec.max_output_tokens > 0) { - defaultMaxTokens = spec.max_output_tokens; - } - - // The same pair the picker reads: `thinkingLevelMapFor` decides which - // levels exist, `effortsFromLevelMap` renders them, so a level offered - // here is a level the composer shows. - let supports: { id: string; name: string }[] | undefined; - if (override?.reasoningEfforts !== undefined) { - supports = override.reasoningEfforts.map((id) => ({ - id, - name: - id === "off" - ? "Off" - : `${id.charAt(0).toUpperCase()}${id.slice(1)}`, - })); - } else if (record.reasoning === undefined) { - const map = thinkingLevelMapFor(spec); - if (map !== undefined) { - supports = effortsFromLevelMap(map); - } - } - - const outRecord: Record = { - ...record, - inputModalities, - name: modelName, - }; - if (record.provider === undefined) { - outRecord.provider = provider; - } - if (record.id === undefined) { - outRecord.id = model; - } - if (effectiveWindow !== undefined) { - outRecord.context = { - ...existingContext, - contextWindow: effectiveWindow, - }; - } - if (defaultMaxTokens !== undefined) { - outRecord.defaultMaxTokens = defaultMaxTokens; - } - if (supports !== undefined && supports.length > 0) { - outRecord.reasoning = { efforts: supports }; - } - // **Route the model on the object the host dispatches on.** - // `dsh-llm-pi-ai` selects the implementation per model — - // `stream: (model, …) => input.api.stream(model, …)`, with the protocol - // materialized as `request.api ?? base?.api ?? routeApi` — so a single - // provider route serves every protocol its models name. That makes this - // object the place to say it, and it is why a model needing Anthropic does - // not need a provider of its own. - // - // The catalog's protocol is the default; `modelRoutes` overrides it, which - // is the layer for an endpoint the shim gets wrong. - const declaredRoute = config.modelRoutes[model]; - const protocol = declaredRoute?.api ?? protocolFor(spec); - if (protocol !== undefined) { - outRecord.api = protocol; - } - // **The endpoint: a declaration outranks our measurement, except where the - // declaration is the vendor's own retired path.** pi's installed catalog - // still says `https://opencode.ai/zen`, and a profile written for the old - // gateway says `…/zen/v1`; both are aliases today (measured: `/zen/v1/…` - // and `/inference/openai/v1/…` both answer 200), but the per-family - // addresses are what the gateway serves, and an anthropic model on a - // `/zen` base is simply wrong. - // - // Anything else is left alone: a deployment pointing this route at its own - // proxy must not have that endpoint rewritten by a bundled snapshot. - const existingBase = - typeof record.baseUrl === "string" - ? record.baseUrl.trim().replace(/\/+$/u, "") - : undefined; - const legacyBase = - existingBase !== undefined && - LEGACY_PLANE_BASES.has(existingBase); - // The catalog's per-family endpoint is a REPAIR for a retired base and a - // FALLBACK when none was given; an endpoint the deployment wrote is kept. - const keepExisting = - existingBase !== undefined && - !legacyBase && - protocol !== undefined; - const measured = - protocol === undefined - ? undefined - : baseForProtocol(route, protocol, config.endpoint); - const baseURL = - declaredRoute?.baseURL ?? - (keepExisting ? existingBase : measured); - if (baseURL !== undefined) { - outRecord.baseUrl = baseURL; - } - return outRecord; - }; - } - return chain; - }, - { id: PATCH_SITE.planeAdapter } - ); - if (stop !== undefined) { - stops.push(stop); - } - } - - // The half dispatch validates against, and the reason a filled picker was - // still not enough. `streamWithSnapshot` resolves the model through - // `modelOf` and then reads the level map off it; `getSupportedThinkingLevels` - // refuses a level whose entry is `null` and refuses `xhigh`/`max` unless the - // entry exists at all - so a level is filled exactly when the model does not - // already take it, and a level the adapter (or the deployment's own row) - // declares keeps its wire spelling. - // - // The map is the one the composer already renders: `thinkingLevelMapFor` is - // the same derivation `decorateModelInfo` uses to build the picker's Effort - // row, so the two read one source rather than two that have to agree. - const stopModelOf = patchMethod( - adapter, - "modelOf", - (unpatched) => { - let chain = ( - snapshot: unknown, - provider: string, - model: string - ): unknown => - Reflect.apply(unpatched, adapter, [snapshot, provider, model]); - for (const route of routes) { - const inner = chain; - const plane = catalogPlaneForRoute(route); - chain = ( - snapshot: unknown, - provider: string, - model: string - ): unknown => { - const answered: unknown = inner(snapshot, provider, model); - if (!isRecord(answered)) { - return answered; - } - const spec = findModelSpecOn(plane, model); - if (spec === undefined) { - return answered; - } - const override = config.modelOverrides[model]; - const levels = - override?.reasoningEfforts === undefined - ? thinkingLevelMapFor(spec) - : Object.fromEntries( - override.reasoningEfforts.map((id) => [id, id] as const) - ); - // No opinion: the catalog does not call the model a reasoning one, and - // the deployment named no level for it either. - if (levels === undefined || Object.keys(levels).length === 0) { - return answered; - } - const declared = isRecord(answered.thinkingLevelMap) - ? answered.thinkingLevelMap - : {}; - // pi's acceptance rule, which is what `resolveReasoningLevel` throws - // against (`getSupportedThinkingLevels`): a model that reasons takes a - // level whose entry is not `null`, and `xhigh`/`max` need an entry to - // exist at all - an absent entry there is not a level, while for the - // base levels it means the level's own name on the wire. - const taken = (level: string): boolean => { - if (answered.reasoning !== true) { - return false; - } - const entry = declared[level]; - if (entry === null) { - return false; - } - return level === "xhigh" || level === "max" - ? entry !== undefined - : true; - }; - const merged: Record = { ...declared }; - let filled = false; - for (const [level, wire] of Object.entries(levels)) { - // A level the model already takes keeps the adapter's own answer: one - // that spells the level itself keeps that spelling, and one that leaves - // it out keeps the level's name on the wire. Only what it REFUSES is - // filled, so the levels that work today keep working byte for byte. - if (wire === null || taken(level)) { - continue; - } - merged[level] = wire; - filled = true; - } - // Nothing to add - and so nothing to say about `reasoning` either, since - // every level is only "taken" for a model that already reasons: hand back - // the adapter's own object rather than an equal copy of it. - if (!filled) { - return answered; - } - return { ...answered, reasoning: true, thinkingLevelMap: merged }; - }; - } - return chain; - }, - { id: PATCH_SITE.planeAdapter } - ); - if (stopModelOf !== undefined) { - stops.push(stopModelOf); - } - } - if (stops.length === 0) { - return undefined; - } - return () => { - for (const undo of stops.toReversed()) { - undo(); - } - }; -}; - -/** - * Wrap the Host's `resolveModelInfo` so a plane route's model reports the - * thinking level the catalog declares and the context window it names. - * - * This is the seam the model picker actually reads. `buildModelCatalog` - * (`dsh-api-session-controller`) calls `ctx.llm.resolveModelInfo` per model and - * renders an Effort row only from what comes back — so a plane route whose - * adapter reports no `reasoning` shows no Effort row at all, which is the exact - * symptom this exists for: `MiniMax-M3` and `MiniMax-M2.7` declare - * `reasoning: true` in the catalog and still offered no thinking level. - * - * A plane route is a profile's own (`opencode`, `opencode-go`), and its adapter - * materializes each declared row OVER the installed catalog's entry for the same - * id: a row that declares no `reasoningEfforts` reports what that snapshot says - * of the id — `reasoning: false`, with no level map, for every id it does not - * carry — beside the row's own window and output cap. - * - * The fields our catalog carries are therefore AUTHORITATIVE here rather than - * advisory: filling only what the adapter left blank let a stale snapshot report - * itself on every surface that showed it, measured as 272k for `gpt-5.4` where - * the vendor and our shim both say 1.05M. Only a model the catalog cannot - * describe is passed through untouched, which is what keeps an id that only the - * installed catalog knows — and a `reasoningEfforts` the row declares beside it - * — reporting its own truth. A `modelOverrides` entry decides the fields it - * names, its window being a request clamped by `maxContextWindow`. The INTERNAL - * routes this plugin mounts already carry the right metadata through - * {@link reasoningEffortsFor}, and are passed through so the two never disagree. - * - * That authority has a cost dispatch still pays: the requested level is - * validated against the ADAPTER's object, not this one, so a level offered here - * and refused there fails the turn — {@link decoratePlaneAdapters} fills that - * object from the same catalog derivation so the two cannot disagree. - * - * @param ctx - host context carrying the LLM registry. - * @param config - the resolved plugin config. - * @returns the disposer restoring the original method, or `undefined`. - */ -export const decorateModelInfo = ( - ctx: CordisContext, - config: ResolvedPluginConfig -): (() => void) | undefined => { - if (!config.enrichModels) { - return undefined; - } - const { llm } = ctx; - if (llm === undefined || typeof llm.resolveModelInfo !== "function") { - return undefined; - } - return patchMethod( - llm, - "resolveModelInfo", - (original) => - async ( - provider: string, - model: string, - signal?: AbortSignal - ): Promise => { - // A provider this plugin does not claim is answered by its own adapter, - // unchanged and un-caught. Our catalog is OpenCode's: substituting it for a - // foreign route's failure, or overwriting a foreign route's limits with it, - // describes a model the user is not calling. - if (!config.providers.has(provider) && !isInternalRoute(provider)) { - return Reflect.apply(original, llm, [provider, model, signal]); - } - let resolved: unknown; - try { - resolved = await Reflect.apply(original, llm, [ - provider, - model, - signal, - ]); - } catch (error) { - // **One throw removes the WHOLE provider group from the picker.** The Host - // builds a group with `Promise.all` over `listModels`, and a rejection - // there is caught per PROVIDER, not per model — so the user loses every - // model on that route, not one row. - // - // That became reachable when `listModels` started publishing the catalog on - // the parent route: the route's own adapter never carried the Responses, - // Messages or Google models, so `resolveModelInfo` may refuse an id it does - // not know. Our catalog does know it, so answer from there rather than - // letting one unknown id take the group down. - // - // A model the catalog cannot describe either is rethrown: inventing an - // answer for an id nobody knows would hide a real problem. - const spec = findModelSpecOn(catalogPlaneForRoute(provider), model); - if (isInternalRoute(provider) || spec === undefined) { - throw error; - } - resolved = {}; - } - // Our own routes carry the real metadata already. - if (isInternalRoute(provider)) { - return resolved; - } - const spec = findModelSpecOn(catalogPlaneForRoute(provider), model); - if (spec === undefined) { - return resolved; - } - // A NON-RECORD answer is the same hazard as a throw, one step quieter: the - // Host reads `resolved.reasoning` immediately after this returns, so - // `undefined` becomes `TypeError: Cannot read properties of undefined` inside - // the group's `Promise.all` — and the whole provider group goes with it. When - // the catalog knows the model, the answer is built from the catalog instead of - // passed through. - const out: Record = isRecord(resolved) - ? { ...resolved } - : {}; - // Our catalog is the FRESHER VINTAGE of the same data, and the divergence is - // measured rather than assumed. pi ships an installed `opencode` catalog the - // plane routes fall back to, and it is a different snapshot of models.dev: - // it says 272k for `gpt-5.4` and 200k for `claude-sonnet-4-5` where the - // vendor and our shim both say 1.05M and 1M, and it carries no - // `thinkingLevelMap` for the `budget_tokens` Claude models where models.dev - // declares a token minimum. - // - // Filling only what the adapter left BLANK let every one of those stale - // values survive, which is what the surfaces then reported — so the fields - // our catalog carries are authoritative here rather than advisory. - const override = config.modelOverrides[model]; - const existingContext = isRecord(out.context) ? out.context : {}; - const requestedWindow = override?.contextWindow; - - if ( - typeof spec.context_window === "number" && - spec.context_window > 0 - ) { - const effectiveWindow = resolveEffectiveContextWindow( - requestedWindow ?? spec.context_window, - config.maxContextWindow - ); - out.context = { - ...existingContext, - contextWindow: effectiveWindow, - }; - } - // The output cap is the same class of staleness, and a wrong one truncates - // rather than merely mis-reports. - if (override?.maxTokens !== undefined) { - out.defaultMaxTokens = override.maxTokens; - } else if ( - typeof spec.max_output_tokens === "number" && - spec.max_output_tokens > 0 - ) { - out.defaultMaxTokens = spec.max_output_tokens; - } - if (override?.name !== undefined) { - out.name = override.name; - } - if (override?.inputModalities !== undefined) { - out.inputModalities = [...override.inputModalities]; - } - if (override?.reasoningEfforts === undefined) { - const map = thinkingLevelMapFor(spec); - const efforts = map === undefined ? [] : effortsFromLevelMap(map); - if (efforts.length > 0) { - out.reasoning = { efforts }; - } else { - // The model takes no thinking level. Leaving the adapter's `reasoning` - // here would offer a control the model refuses — and for a model our - // catalog marks non-reasoning, that is exactly the stale shape. - delete out.reasoning; - } - } else { - out.reasoning = { - efforts: override.reasoningEfforts.map((id) => ({ - id, - name: - id === "off" - ? "Off" - : `${id.charAt(0).toUpperCase()}${id.slice(1)}`, - })), - }; - } - return out; - }, - { id: PATCH_SITE.modelInfo } - ); -}; diff --git a/src/pi-provider.ts b/src/pi-provider.ts deleted file mode 100644 index 4bb0bca6..00000000 --- a/src/pi-provider.ts +++ /dev/null @@ -1,445 +0,0 @@ -/** - * Build pi-ai providers for the gateway's two planes, from our own catalog. - * - * The plugin mounts its routes through `dsh-llm-pi-ai`, which builds a - * hand-declared route from a three-entry protocol table. That table is why - * `@ai-sdk/google` could not be served: `google-generative-ai` is implemented - * by pi-ai but is not one of the three names a route may declare. This module - * builds the provider directly instead, so every protocol pi implements is - * reachable and the routing decision moves to each model's `api` field. - * - * Two measured facts shape it, neither derivable from the protocol name: - * - * - **The base URL is per protocol, not per plane.** The Anthropic SDK appends - * its own `/v1/messages`, so its base is one level shorter than the others. - * Read off pi's own `opencode` provider, which records it per model. - * - **The session header must be set on the request.** pi ships its own, but it - * defers when the header is already present, so wrapping each `api` here puts - * ours on every protocol and leaves pi's a no-op. - * - * pi-ai is injected rather than imported: the plugin must not bundle a second - * copy of it, and the caller is the only place that knows how to reach the one - * the host already loaded. - * - * @module dsh-opencode-patch/pi-provider - */ - -import type { CatalogModelSpec } from "./catalog-data.ts"; -import { OPENCODE_FAMILY_API, OPENCODE_PLANE_API } from "./catalog-data.ts"; -import type { EndpointFamily } from "./config-values.ts"; -import { - ANTHROPIC_SDK, - GOOGLE_SDK, - RESPONSES_SDK, -} from "./responses-routes.ts"; - -/** The gateway plane a provider serves. */ -export type Plane = "opencode" | "opencode-go"; - -/** - * The base URL each protocol uses on each plane. - * - * Read from pi-ai's own `opencode` and `opencode-go` providers rather than - * inferred: the Anthropic entry is deliberately one level shorter because that - * SDK appends `/v1/messages` itself. - */ -/** - * The endpoint each protocol's requests go to, per plane. - * - * The base comes from the catalog - the field the client itself consumes - * (`models-dev.ts:107` lifts the provider's `api` into `settings.baseURL`), and - * which the shim now carries as `OPENCODE_PLANE_API`. It was hard-coded here - * instead, and when the vendor moved `models.dev`'s provider entry to - * `…/inference/v1` the constant was left on `…/zen/v1`: a deployment would have - * kept using the older endpoint while its own catalog named the newer one. - * - * The Anthropic path differs from the others by design (`/messages` not `/chat/ - * completions`), so only the suffix changes, and the suffix is per protocol - * rather than per model. - */ -const PLANE_BASE: Readonly> = { - opencode: OPENCODE_PLANE_API.zen, - "opencode-go": OPENCODE_PLANE_API.go, -}; - -/** - * The older endpoint each plane's requests used to go to. - * - * Still served — measured — and the reason an endpoint choice exists at all: - * the catalog moved to `…/inference/v1`, but a deployment whose account is only - * entitled on the older family needs `/zen/v1` and no catalog change will say - * so. `responses-routes.ts`'s `PLANES` is this table, kept as the declared name - * so the two cannot drift. - */ -export const LEGACY_PLANE_API: Readonly> = { - opencode: "https://opencode.ai/zen/v1", - "opencode-go": "https://opencode.ai/zen/go/v1", -}; - -/** - * The base URL one plane's requests go to under one endpoint family. - * - * `auto` is an alias of `modern`: both answer the per-protocol inference - * family's plane base, and `legacy` pins the older single base per plane. The - * two families are independently entitled, which is why a user can choose - * rather than being carried along by whatever the catalog happens to name. - * - * @param plane - the gateway plane. - * @param family - the endpoint family to resolve. - * @returns that plane's base URL. - */ -export const planeBaseFor = (plane: Plane, family: EndpointFamily): string => { - if (family === "legacy") { - return LEGACY_PLANE_API[plane]; - } - return family === "modern" - ? OPENCODE_PLANE_API[plane === "opencode-go" ? "go" : "zen"] - : PLANE_BASE[plane]; -}; - -/** - * The protocol's path on its family's base, or `null` when the SDK wants the base - * without its version segment. - * - * A family's `api` is the base INCLUDING the version segment - `…/openai/v1`, - * `…/anthropic/v1`, `…/google/v1beta` - so the versioned protocols are the base - * with nothing added, and the Anthropic endpoint (`/messages`) is the base - * without that segment: pi-ai's SDK appends `/v1/messages` itself. - */ -const PROTOCOL_SUFFIX: Readonly> = { - "anthropic-messages": null, - "google-generative-ai": "", - "openai-completions": "", - "openai-responses": "", -}; - -/** The wire family a protocol belongs to. Each family has its own base. */ -type WireFamily = "anthropic" | "google" | "openai"; - -const FAMILY_OF_PROTOCOL: Readonly> = { - "anthropic-messages": "anthropic", - "google-generative-ai": "google", - "openai-completions": "openai", - "openai-responses": "openai", -}; - -/** - * The inference family's base, per plane and per wire family — the FALLBACK. - * - * **Measured from the vendor's own `GET /console/api/config`, not derived.** The - * gateway does not serve one base per plane with the protocol appended: each - * family lives at its own path, and the vendor's client hands the SDK exactly - * the `api` its config reports for that model. - * - * Deriving these by stripping `/v1` off one plane URL - which is what this - * module used to do - sent every Anthropic model to `…/inference/openai` and - * every Google model to `…/inference/openai/v1`. Measured on the live config: - * `opencode` declares `…/inference/anthropic/v1` for 18 models and - * `…/inference/google/v1beta` for 8; `opencode-go` declares the `go/` variants - * for 5 and 1. Thirty-two models, reachable only through their own family. - * - * The legacy (`…/zen/v1`) family genuinely IS one base for every protocol, which - * is why it keeps the old derivation. - */ -/** - * The SDK package each wire family is named by in the catalog. - * - * `""` is the provider default — the completions family names no package at all, - * so it is looked up under the empty key first and under `@ai-sdk/openai` (the - * same endpoint, declared for the Responses models) second. - */ -const FAMILY_PACKAGES: Readonly> = { - anthropic: ["@ai-sdk/anthropic"], - google: ["@ai-sdk/google"], - openai: ["", "@ai-sdk/openai"], -}; - -/** - * A family's endpoint as the CATALOG declares it, or `undefined` when the - * generated map does not name that package yet. - * - * @param plane - the gateway plane. - * @param family - the wire family. - * @returns the declared endpoint, when there is one. - */ -const declaredFamilyBase = ( - plane: Plane, - family: WireFamily -): string | undefined => { - // The generated map is keyed by the catalog's plane names, not the route names. - const declared = OPENCODE_FAMILY_API[plane === "opencode-go" ? "go" : "zen"]; - for (const pkg of FAMILY_PACKAGES[family]) { - const api = declared[pkg]; - if (typeof api === "string" && api.length > 0) { - return api; - } - } - return undefined; -}; - -const INFERENCE_BASE: Readonly< - Record>> -> = { - opencode: { - anthropic: "https://opencode.ai/inference/anthropic/v1", - google: "https://opencode.ai/inference/google/v1beta", - openai: "https://opencode.ai/inference/openai/v1", - }, - "opencode-go": { - anthropic: "https://opencode.ai/inference/go/anthropic/v1", - google: "https://opencode.ai/inference/go/google/v1beta", - openai: "https://opencode.ai/inference/go/openai/v1", - }, -}; - -/** - * Every protocol on one plane under one endpoint family, sharing the base. - * - * Memoised per family: the table is rebuilt at most once per family per process, - * and the map keys are exactly the accepted {@link EndpointFamily} values, so an - * unknown family falls through to auto's answer (an alias of modern) rather - * than to `undefined`. - */ -const BASES_BY_FAMILY = new Map(); - -const basesFor = (family: EndpointFamily): BaseTable => { - const cached = BASES_BY_FAMILY.get(family); - if (cached !== undefined) { - return cached; - } - const table: BaseTable = { - opencode: withPlaneBase("opencode", family), - "opencode-go": withPlaneBase("opencode-go", family), - }; - BASES_BY_FAMILY.set(family, table); - return table; -}; - -/** - * Every protocol on one plane and family, each on its own family's base. - * - * Legacy is one base for all four protocols; the inference family is not, and - * pretending otherwise is the bug this encodes against. - */ -function withPlaneBase( - plane: Plane, - family: EndpointFamily -): Readonly> { - const out: Record = {}; - for (const [protocol, suffix] of Object.entries(PROTOCOL_SUFFIX)) { - const wire = FAMILY_OF_PROTOCOL[protocol] ?? "openai"; - const base = - family === "legacy" - ? planeBaseFor(plane, "legacy") - : // The catalog's own field, with the measured table as the fallback for a - // shim generated before the map existed. - (declaredFamilyBase(plane, wire) ?? INFERENCE_BASE[plane][wire]); - out[protocol] = - suffix === null ? base.replace(/\/v1$/, "") : `${base}${suffix}`; - } - return out; -} - -/** One plane's protocol table, keyed by protocol. */ -type BaseTable = Readonly>>>; - -/** - * The base URL each protocol uses on each plane under auto, an alias of modern. - * - * Kept as the default table for callers that name no family; read - * {@link baseForProtocol} with an explicit family once one is known. - */ -export const BASE_FOR_PROTOCOL: BaseTable = basesFor("auto"); - -/** - * The protocol each SDK names, for a provider built here. - * - * Deliberately NOT `PROTOCOL_FOR_SDK`: that table is the wrapper's, and it - * lists only the protocols a hand-declared route may name. Building the - * provider ourselves lifts that limit, so this table carries every protocol pi - * implements that the gateway is measured to serve - which is four, and one - * more than a route could ever name. - * - * `@ai-sdk/mistral` is absent on purpose. pi implements - * `mistral-conversations`, but driving it at the gateway produces a request - * whose URL this harness could not even observe, and the account has no - * entitlement to test it against. It stays unserved until that is measured, - * rather than being wired on the strength of a protocol existing. - */ -export const GATEWAY_PROTOCOL_FOR_SDK: Readonly> = { - [RESPONSES_SDK]: "openai-responses", - [ANTHROPIC_SDK]: "anthropic-messages", - [GOOGLE_SDK]: "google-generative-ai", -}; - -/** The protocol a model speaks, or undefined when its SDK has no mapping. */ -export const protocolFor = (spec: CatalogModelSpec): string | undefined => - spec.provider_npm === undefined - ? "openai-completions" - : GATEWAY_PROTOCOL_FOR_SDK[spec.provider_npm]; - -/** Whether a string names a plane this table carries. */ -const isPlane = (value: string): value is Plane => - value === "opencode" || value === "opencode-go"; - -/** - * The base URL one protocol of one plane is served from. - * - * The table is keyed by {@link Plane} so a missing plane is a type error where - * the table is written. Callers walk a route table whose key is a plain string, - * so they come through here: a plane the table does not carry has no base, and - * saying so is better than indexing past the end of a record. - * - * @param plane - the gateway plane. - * @param protocol - the protocol the request speaks. - * @param family - the endpoint family; auto (an alias of modern) by default. - * @returns the base URL, or undefined when the table does not carry that plane. - */ -export const baseForProtocol = ( - plane: string, - protocol: string, - family: EndpointFamily = "auto" -): string | undefined => - isPlane(plane) ? basesFor(family)[plane]?.[protocol] : undefined; - -/** - * The pi model object for one catalogued model. - * - * pi reads `api` to pick a protocol implementation and `baseUrl` to know where - * to send it, so both have to be right per model. The cost field is pi's own - * vocabulary: camelCase where the catalog is snake_case, and its rates are per - * million tokens, which is what the catalog already carries. - * - * **The installed catalog entry is the floor, never the whole object.** Building - * these models field by field drops everything pi knows that this module does - * not model — the compatibility switches a Claude or Grok request needs, the - * `reasoning` flag, the `thinkingLevelMap` that maps a thinking level onto the - * wire's own spelling, the `inputLimits` that bound an image. The wrapper's own - * model materialization spreads the catalog entry for the same reason, and says - * so: enumerating would silently drop every field the package does not model. - * - * So the entry is spread first and only then overlaid, which also makes the - * precedence deliberate: our catalog is fresher than the installed one for the - * fields we set, and the installed one is the only source for the fields we do - * not know about. - * - * @param plane - the gateway plane this model is served on. - * @param spec - the catalogued model. - * @param protocol - the protocol it speaks, from {@link protocolFor}. - * @param base - pi's installed catalog entry for this id, when it has one. - * @returns the model object pi's provider dispatches on. - */ -/** - * The thinking levels a model accepts, as pi's map. - * - * pi's `ThinkingLevelMap` is `Partial>` - * — each of pi's levels mapped onto the spelling the model's own wire uses, with - * `null` marking a level it does not take. The catalog says which levels a model - * has and says nothing about the wire's spelling, so the mapping is the identity - * where the two agree, and every level the catalog does not list is `null` rather - * than absent: absent means "use the provider default", which would offer a level - * the model refuses. - * - * Only the shapes the catalog actually uses are handled — `effort` with a value - * list, and `toggle` for on/off. `budget_tokens` carries a minimum rather than a - * list of levels, so it contributes no map: pi's token-budget field is a separate - * mechanism and guessing one here would be worse than offering none. - * - * @param spec - the catalogued model. - * @returns the map, or `undefined` when the model declares no levels. - */ -export const thinkingLevelMapFor = ( - spec: CatalogModelSpec -): Partial> | undefined => { - if (spec.reasoning !== true) { - return undefined; - } - const options = spec.reasoning_options ?? []; - const levels = options.find( - (option) => option.type === "effort" && (option.values?.length ?? 0) > 0 - )?.values; - if (levels !== undefined) { - // `off` is part of the map too, and `null` there means the model has no - // off switch - which is what the installed catalog says for these models. - const map: Record = { off: null }; - for (const level of ALL_THINKING_LEVELS) { - map[level] = levels.includes(level) ? level : null; - } - return map; - } - if (options.some((option) => option.type === "toggle")) { - // On or off: every level is the same "on", and `off` is the model's own. - const map: Record = { off: null }; - for (const level of ALL_THINKING_LEVELS) { - map[level] = "on"; - } - return map; - } - // When a model declares reasoning: true without explicit reasoning_options - // (e.g. minimax-m2.5, minimax-m2.7, or minimax-m3 on Zen), offer standard - // thinking levels so DSH harness exposes the thinking controls. - const map: Record = { off: null }; - for (const level of ALL_THINKING_LEVELS) { - map[level] = ["low", "medium", "high"].includes(level) ? level : null; - } - return map; -}; - -/** pi's levels, in its own order, so the map is complete rather than partial. */ -const ALL_THINKING_LEVELS = [ - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -] as const; - -/** - * The pi model object for one catalogued model. - * - * pi reads `api` to pick a protocol implementation and `baseUrl` to know where - * to send it, so both have to be right per model. - * - * @param plane - the gateway plane this model is served on. - * @param spec - the catalogued model. - * @param protocol - the protocol it speaks, from {@link protocolFor}. - * @param base - pi's installed catalog entry for this id, when it has one. - * @param family - the endpoint family to send it to; auto (an alias of modern) - * by default. - * @returns the model object pi's provider dispatches on. - */ -export const piModelFor = ( - plane: Plane, - spec: CatalogModelSpec, - protocol: string, - base?: object, - family: EndpointFamily = "auto" -) => ({ - ...base, - api: protocol, - baseUrl: baseForProtocol(plane, protocol, family), - contextWindow: spec.context_window, - cost: { - cacheRead: spec.cost?.cache_read ?? 0, - cacheWrite: spec.cost?.cache_write ?? 0, - input: spec.cost?.input ?? 0, - output: spec.cost?.output ?? 0, - }, - id: spec.id, - input: spec.input_modalities, - maxTokens: spec.max_output_tokens, - name: spec.name, - provider: plane, - // Explicit, because the spread above is only a floor: pi's installed catalog - // need not carry these models at all, and when it does not, `reasoning` and - // `thinkingLevelMap` were simply absent - which is the thinking level the - // picker could not offer for any model. - // Required by pi's Model, not optional: a model that declares no reasoning - // takes none, and `false` is what that means. Omitting it left an object the - // package's own type says cannot exist. - reasoning: spec.reasoning === true, - ...(thinkingLevelMapFor(spec) === undefined - ? {} - : { thinkingLevelMap: thinkingLevelMapFor(spec) }), -}); diff --git a/src/responses-provider.ts b/src/responses-provider.ts deleted file mode 100644 index a4aa707f..00000000 --- a/src/responses-provider.ts +++ /dev/null @@ -1,1215 +0,0 @@ -/** - * Serve the gateway's non-default APIs from the plugin, so a model the user - * already picked simply works. - * - * The problem this solves: OpenCode Zen is one route whose models do not all - * speak one wire format. models.dev names a per-model SDK for exactly those that - * differ — 30 bundled Zen models name `@ai-sdk/openai` (Responses API) and 17 - * name `@ai-sdk/anthropic` (Messages API), against a route declared as - * `openai-completions`. DSH cannot express "this model speaks a different - * format": `llm-pi-ai` carries one `api` per ROUTE, and `llm.registerAdapter` - * refuses a route that already has an adapter. So the format has to live on a - * route, and the model has to be dispatched to the route whose `api` names it. - * - * ## Why this mounts the host's own plugin instead of building an adapter - * - * `llm-pi-ai` is a plugin, not a library. Its package root exports the plugin - * contract (`apply`/`inject`/`name`) plus what a configuration surface needs; - * the pieces that turn a raw profile into a serviceable route are internal, and - * its published `exports` map advertises `"./src/*"` while `files` ships only - * `lib/` — so that path is dead in every installed copy. `apply` is the - * supported entry and resolves all of it itself, which is why nothing here - * reimplements a protocol or copies the host's resolver. - * - * ## The four collisions a second instance causes, and how each is handled - * - * | collision | why | handled by | - * | --- | --- | --- | - * | `DUPLICATE_FLOW` | `apply` registers one authorization flow per installed catalog provider | `isolate('authorization')`, so the `inject` never fires | - * | `DUPLICATE_DIRECTORY` | `apply` declares the WHOLE catalog, not just the routes being added | the facade answers `registerConfigurableProviders` locally | - * | `DUPLICATE_DISCOVERY` | discovery is keyed by settings namespace, and a nested entry inherits its parent's id | the facade answers `registerModelDiscovery` locally | - * | `providers.get expected object` | the registry keys its runtime by `apply` identity, so the FIRST instance's schema validates the second mount | the mount is handed RAW config, never a pre-validated `Config` | - * - * The facade is a local view for the mounted instance only. It forwards - * `registerAdapter` to the real service — so the child fiber still owns and - * releases the registration — and suppresses the two publishing calls. - * - * @module dsh-opencode-patch/responses-provider - */ - -import { createRequire } from "node:module"; -import { pathToFileURL } from "node:url"; - -import { DEFAULT_USAGE_KEY_ENV, type ResolvedPluginConfig } from "./config.ts"; -import type { CordisContext } from "./cordis-context.ts"; -import { declaredProviderModels } from "./go-discovery.ts"; -import { loadHostPi, registerGoogleBridge } from "./google-bridge.ts"; -import { - type CatalogModelSpec, - getLiveGoCatalog, - getLiveZenCatalog, -} from "./models-catalog.ts"; -import { baseForProtocol, protocolFor, type Plane } from "./pi-provider.ts"; -import { - GOOGLE_INTERNAL_ROUTE, - GO_GOOGLE_INTERNAL_ROUTE, - GOOGLE_SDK, - PROTOCOL_FOR_SDK, - ROUTE_FOR_PLANE_PROTOCOL, - SELF_MOUNTED_ROUTES, -} from "./responses-routes.ts"; -import { isFunctionLike, isRecord } from "./utils/guards.ts"; - -/** The host plugin whose `apply` this module mounts. */ -const PI_AI_PACKAGE = "@deepseek-ai/dsh-llm-pi-ai"; - -/** Services hidden from the mounted instance; see the module header. */ -const HIDDEN_SERVICES: readonly string[] = ["authorization", "settings"]; - -/** The credential the README tells a user to store. */ -const DEFAULT_CREDENTIAL_REF = "OPENCODE_API_KEY"; - -/** The gateway's endpoint. */ - -/** - * The sentinel that makes the stored credential resolvable at all: pi-ai's - * `getClientApiKey` throws when a route names neither a key nor an - * `authorization` header, before `fetch` — so the key the user already stored - * never gets a chance to be resolved without it. The fetch patch swaps it for - * the real one. - */ -const KEY_SENTINEL = "Bearer unused"; - -/** One model entry, in the shape `llm-pi-ai`'s config schema expects. */ -interface ModelProfile { - contextWindow: number; - id: string; - input: string[]; - maxTokens: number; - name: string; - reasoningEfforts?: Record | false; -} - -const ALL_THINKING_LEVELS: ReadonlySet = new Set([ - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -]); - -/** - * Derive the `reasoningEfforts` dictionary that `dsh-llm-pi-ai` expects in - * a model profile, from the model's catalog specification. - * - * `dsh-llm-pi-ai` in `resolveModelReasoning` checks `entry.reasoningEfforts`. - * Without it, it falls back to pi's installed catalog entry — which has no - * knowledge of OpenCode models, setting `reasoning: false` and leaving the - * model with no thinking levels in the DSH composer. - */ -export const reasoningEffortsFor = ( - spec: CatalogModelSpec -): Record | undefined => { - if (spec.reasoning !== true) { - return undefined; - } - const options = spec.reasoning_options ?? []; - const effortOption = options.find( - (option) => option.type === "effort" && (option.values?.length ?? 0) > 0 - ); - if (effortOption?.values !== undefined) { - const efforts: Record = { off: null }; - for (const v of effortOption.values) { - if (v === "none" || v === "off") { - efforts.off = null; - } else if (ALL_THINKING_LEVELS.has(v)) { - efforts[v] = v; - } - } - const hasActive = Object.keys(efforts).some((k) => k !== "off"); - if (hasActive) { - return efforts; - } - } - if (options.some((option) => option.type === "toggle")) { - return { - off: null, - low: "low", - medium: "medium", - high: "high", - }; - } - // Model declares reasoning: true without explicit reasoning_options (e.g. minimax-m2.5, minimax-m2.7, minimax-m3 on Zen) - return { - off: null, - low: "low", - medium: "medium", - high: "high", - }; -}; - -/** - * Read a value that may be an array or any iterable as an array. - * - * The loader's `entries()` is a generator, so it is not an array; a mock or a - * future version may hand over either. - * - * @param value - the candidate. - * @returns the items, or `[]` when the value is not iterable. - */ -const toArray = (value: unknown): unknown[] => { - if (Array.isArray(value)) { - return value; - } - if (value === null || typeof value !== "object") { - return []; - } - const iterator: unknown = Reflect.get(value, Symbol.iterator); - if (typeof iterator !== "function") { - return []; - } - const items: unknown[] = []; - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the Symbol.iterator check above is the runtime proof - for (const item of value as Iterable) { - items.push(item); - } - return items; -}; - -/** - * The loader service, off the context or through the service registry. - * - * `ctx.loader` is the accessor cordis installs for the service; `ctx.get` is the - * untyped escape hatch, kept because a context that exposes only the latter is - * still a context this plugin can work in. - * - * @param ctx - the host context. - * @returns the loader, or `undefined` when the host serves none. - */ -const loaderOf = (ctx: unknown): Record | undefined => { - if (!isRecord(ctx)) { - return undefined; - } - // Guarded, because THIS read throws: cordis answers - // `cannot get property "loader" without inject` for an undeclared name, and - // this plugin declares only `llm`. An unguarded read here failed the whole - // mount, not just the lookup. - try { - const { loader: direct } = ctx as { loader?: unknown }; - if (isRecord(direct) && isFunctionLike(direct.entries)) { - return direct; - } - } catch { - // Not injected: the `ctx.get` path below is the one that works. - } - if (!isFunctionLike(ctx.get)) { - return undefined; - } - let viaGet: unknown; - try { - viaGet = Reflect.apply(ctx.get, ctx, ["loader"]); - } catch { - return undefined; - } - return isRecord(viaGet) && isFunctionLike(viaGet.entries) - ? viaGet - : undefined; -}; - -/** Every entry the host's loader holds, or `[]` when there is no loader. */ -const loaderEntries = (ctx: unknown): unknown[] => { - const loader = loaderOf(ctx); - if (loader === undefined) { - return []; - } - const { entries } = loader; - if (typeof entries !== "function") { - return []; - } - try { - return toArray(Reflect.apply(entries, loader, [])); - } catch { - return []; - } -}; - -/** - * The plugin behind a loader entry that never kept the import result. - * - * One loader generation stores the raw module on the entry (`moduleNamespace`); - * the other hands the import result straight to the registry and keeps only the - * fiber, so the two pieces needed to mount it again are read off that fiber: - * the registry's resolved callback on the fiber's runtime, and the - * registry-normalized `inject`. `Inject.resolve` maps an already-normalized - * `{ service: null }` onto itself, so the synthesized record mounts exactly as - * the module namespace would. - * - * The `inject` is required, not cosmetic: it is what lets the plugin's first - * `ctx.` property access resolve at all, so an entry whose fiber - * carries none is reported as unusable rather than mounted into a crash. - * - * @param entry - the loader entry whose `options.name` matched the package. - * @returns the mountable record, or `undefined` when the fiber carries none. - */ -const pluginFromFiber = ( - entry: Record -): Record | undefined => { - const { fiber } = entry; - if (!isRecord(fiber)) { - return undefined; - } - const { runtime } = fiber; - if (!isRecord(runtime) || !isFunctionLike(runtime.callback)) { - return undefined; - } - if (!isRecord(fiber.inject)) { - return undefined; - } - return { apply: runtime.callback, inject: fiber.inject }; -}; - -/** - * The loader entry the composition loaded the host plugin under. - * - * @param ctx - host context carrying the loader. - * @returns the entry, or `undefined` when no entry names the package. - */ -const piAiEntry = (ctx: unknown): Record | undefined => { - const entry = loaderEntries(ctx).find( - (candidate) => - isRecord(candidate) && - isRecord(candidate.options) && - candidate.options.name === PI_AI_PACKAGE - ); - return isRecord(entry) ? entry : undefined; -}; - -/** - * The host's already-loaded `llm-pi-ai`, asked of the loader rather than guessed - * from the filesystem. - * - * The loader imported the plugin in the first place, so its entry carries — or - * carried — the raw import result. That is the only way to reach the plugin - * which does not encode a package-manager layout — this repository deliberately - * does not depend on `llm-pi-ai`, because doing so drags in roughly a thousand - * lockfile lines and makes pnpm refuse the install over ignored build scripts. - * - * Two loader generations answer differently and both are in the wild, so both - * shapes are read here: one keeps the raw module on the entry, and one keeps - * only the fiber ({@link pluginFromFiber} reconstructs that). `unwrapExports` - * is the loader's own normalization, so a default-export or CJS-interop shape - * needs no second guess. - * - * @param ctx - host context carrying the loader. - * @returns the plugin, or `undefined` when no entry carries a usable one. - */ -export const loadPiAi = (ctx: unknown): Record | undefined => { - const loader = loaderOf(ctx); - const entry = piAiEntry(ctx); - if (entry === undefined) { - return undefined; - } - if (entry.moduleNamespace !== undefined) { - const unwrapped = - loader !== undefined && isFunctionLike(loader.unwrapExports) - ? Reflect.apply(loader.unwrapExports, loader, [entry.moduleNamespace]) - : entry.moduleNamespace; - return isRecord(unwrapped) && isFunctionLike(unwrapped.apply) - ? unwrapped - : undefined; - } - return pluginFromFiber(entry); -}; - -/** - * The raw module namespace, for the one thing a fiber cannot carry. - * - * The Google bridge constructs the wrapper's own `PiAiAdapter`, and the class - * lives on the module namespace — which one loader generation keeps on its - * entry and the other drops entirely ({@link pluginFromFiber} reads the rest - * off the fiber). The registry's runtime record does not keep it either: it - * holds `{ name, callback, fibers, Config }` and nothing else. - * - * The module is still reachable, because it is the SAME module: the loader - * resolved the entry's own name against the context's base URL, so resolving - * that name from the same base reaches the same file, and Node's module cache - * hands back the namespace the loader already executed. Nothing is imported - * twice and nothing is guessed from a package-manager layout. - * - * @param ctx - host context carrying the loader and its base URL. - * @returns the namespace, or `undefined` when no entry names the package or - * the module cannot be resolved from the base URL. - */ -export const loadPiAiModule = async ( - ctx: unknown -): Promise | undefined> => { - if (piAiEntry(ctx) === undefined) { - return undefined; - } - const base = isRecord(ctx) ? ctx.baseUrl : undefined; - if (typeof base !== "string" || base.length === 0) { - return undefined; - } - try { - // The base URL is the directory the loader resolves bare specifiers - // against, so the sibling file only anchors `createRequire` to that - // directory; it is never read. - const resolve = createRequire(new URL("noop.js", base)); - const namespace: unknown = await import( - pathToFileURL(resolve.resolve(PI_AI_PACKAGE)).href - ); - return isRecord(namespace) ? namespace : undefined; - } catch { - return undefined; - } -}; - -/** - * The host record the GOOGLE BRIDGE needs: one carrying `PiAiAdapter`. - * - * {@link loadPiAi} answers the MOUNT's question — a callback and an inject — - * and on the fiber-only loader generation that record carries no classes. The - * bridge builds its adapter from the module's own class, so when the entry - * kept no namespace the module is asked directly ({@link loadPiAiModule}); - * when even that fails the mount's record is handed back, and the bridge - * stands down rather than inventing an adapter. - * - * @param ctx - host context carrying the loader. - * @returns the best available host record, or `undefined` when there is none. - */ -export const loadPiAiAdapterHost = async ( - ctx: unknown -): Promise | undefined> => { - const host = loadPiAi(ctx); - if (isRecord(host) && isFunctionLike(host.PiAiAdapter)) { - return host; - } - return (await loadPiAiModule(ctx)) ?? host; -}; - -/** - * The credential reference the composition already resolves for `opencode`. - * - * A deployment that named its own environment variable must not be asked to - * state it again here: pi-ai resolves the reference through the credentials - * service, so the same reference is the same stored record, and a different one - * would be a route that authenticates as nobody. - * - * @param ctx - host context carrying the loader. - * @returns the declared reference, or the documented default. - */ -export const inheritedCredentialRef = ( - ctx: unknown, - plane: Plane = "opencode" -): string => { - for (const entry of loaderEntries(ctx)) { - if (!isRecord(entry) || !isRecord(entry.options)) { - continue; - } - // `options` is already known to be a record: the guard above rejected - // everything else. The repeat was unreachable, and a branch no input can - // take is a branch no test can cover. - const { options } = entry; - const { config } = options; - if (!isRecord(config) || !isRecord(config.providers)) { - continue; - } - const source = config.providers[plane]; - if (!isRecord(source)) { - continue; - } - const ref = source.apiKeyEnv; - if (typeof ref === "string" && ref.length > 0) { - return ref; - } - } - return plane === "opencode-go" - ? DEFAULT_USAGE_KEY_ENV - : DEFAULT_CREDENTIAL_REF; -}; - -/** - * Every model the gateway serves on the API one SDK names. - * - * The default catalog unions BOTH planes, which is the right answer to "what - * does this gateway serve". It is the wrong answer for the routes this module - * mounts: they carry Zen's `baseURL`, and the Go plane names models the Zen - * endpoint does not serve, so offering one resolves to "model not found" against - * the route's own endpoint. The mount therefore passes {@link getLiveZenCatalog} - * explicitly. - * - * @param sdk - the models.dev `provider.npm` value the route serves. - * @param catalog - the catalog to read; both planes by default. - * @returns one profile per model, deduplicated. - */ -export const modelsForSdk = ( - sdk: string, - catalog: readonly CatalogModelSpec[] = [ - ...getLiveZenCatalog(), - ...getLiveGoCatalog(), - ] -): ModelProfile[] => { - const seen = new Set(); - const profiles: ModelProfile[] = []; - for (const spec of catalog) { - if (spec.provider_npm !== sdk || seen.has(spec.id)) { - continue; - } - seen.add(spec.id); - const reasoningEfforts = reasoningEffortsFor(spec); - profiles.push({ - contextWindow: spec.context_window, - id: spec.id, - input: [...spec.input_modalities], - maxTokens: spec.max_output_tokens, - name: spec.name, - ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }), - }); - } - return profiles; -}; - -/** - * The provider profile for one route, in the shape the config schema takes. - * - * The mounted plugin resolves it — defaults, serviceable models and all — - * exactly as it would a profile the user wrote. - * - * @param protocol - the pi-ai protocol the route's `api` names. - * @param models - the models to serve. - * @param apiKeyEnv - the credential reference the composition already uses. - * @returns the profile, keyed by nothing yet (the caller keys it). - */ -const providerProfile = ( - protocol: string, - models: readonly ModelProfile[], - apiKeyEnv: string, - baseURL: string -) => ({ - api: protocol, - apiKeyEnv, - baseURL, - headers: { authorization: KEY_SENTINEL }, - models: [...models], -}); - -/** Every SDK that names one protocol — a protocol may be named by more than one. */ -const sdksForProtocol = (protocol: string): string[] => - Object.keys(PROTOCOL_FOR_SDK).filter( - (sdk) => PROTOCOL_FOR_SDK[sdk] === protocol - ); - -/** - * The catalog and credential of one plane. - * - * A route carries ONE plane: its base URL, its catalog and its credential all - * come from the same account. Mixing them resolves to "model not found" against - * the route's own endpoint, which is why the mount reads all three here rather - * than from a module constant. - */ -const planeSource = (plane: string, ctx: unknown) => - plane === "opencode-go" - ? { - apiKeyEnv: inheritedCredentialRef(ctx, "opencode-go"), - catalog: getLiveGoCatalog(), - } - : { apiKeyEnv: inheritedCredentialRef(ctx), catalog: getLiveZenCatalog() }; - -/** - * The route names the registry actually holds, read from its raw map. - * - * The map, not `listProviders()`: that method is Remote-decorated, and the - * decorated view under-reports — measured, the map held `opencode-responses` - * while `listProviders()` did not list it, which is what let a mount try to - * register it a second time. A host that stops exposing the map falls back to - * the decorated list, which is still better than assuming nothing is declared. - * - * @param llm - the `llm` service. - * @returns the route names, or `undefined` when no raw map is readable. - */ -const adapterRoutes = (llm: unknown): Set | undefined => { - if (!isRecord(llm)) { - return undefined; - } - const raw: unknown = llm.adapters; - return raw instanceof Map ? new Set([...raw.keys()].map(String)) : undefined; -}; - -/** The routes the host already serves, so a declared one is never overridden. */ -const declaredRoutes = (llm: CordisContext["llm"]): Set => { - const fromMap = adapterRoutes(llm); - if (fromMap !== undefined) { - return fromMap; - } - const listed = llm?.listProviders?.(); - return new Set( - Array.isArray(listed) - ? listed.flatMap((route) => - isRecord(route) && typeof route.id === "string" ? [route.id] : [] - ) - : [] - ); -}; - -/** Withdraw one registration, whichever handle shape the registry returned. */ -const releaseRegistration = (handle: unknown): void => { - try { - if (typeof handle === "function") { - Reflect.apply(handle, undefined, []); - return; - } - if (isRecord(handle) && typeof handle.dispose === "function") { - Reflect.apply(handle.dispose, handle, []); - } - } catch { - // A registration that cannot be withdrawn must not stop the unwind. - } -}; - -/** - * A handle-shaped no-op, for a forwarded registration reduced to nothing. - * - * The mounted wrapper stores whatever `registerAdapter` returns and may call - * `replace` on it later, so the shape has to match a real handle; withdrawal - * stays with the real handle's own disposer. - */ -const NOOP_HANDLE: (() => void) & { replace: () => void } = Object.assign( - () => { - // Nothing was registered, so there is nothing to withdraw. - }, - { - replace: (): void => { - // Nothing was registered, so there is nothing to replace. - }, - } -); - -/** - * A view of the real `llm` service for the mounted instance. - * - * `registerAdapter` is forwarded — bound to the real service, so the child fiber - * owns what it registers — and every handle it returns is captured, which is - * what lets {@link registerResponsesProvider}'s disposer withdraw the routes - * deterministically rather than relying on the fiber's own unwind. - * - * The forward re-checks the routes against the raw adapter map first. The - * registry's registration is all-or-nothing — one already-registered route - * makes it throw before any of the others land — so a route something else - * serves is dropped here and the rest of the mount survives. That map is the - * same authority {@link declaredRoutes} reads, so the two views agree. - * - * The two publishing calls are answered locally, which is what keeps a second - * instance from colliding with the host's own directory and discovery - * registrations. Every other member passes through with the correct receiver. - * - * @param real - the real `llm` service. - * @param captured - collects the handles `registerAdapter` returns. - * @returns the facade, or `real` when it is not an object. - */ -const llmFacade = (real: unknown, captured: unknown[]): unknown => { - if (typeof real !== "object" || real === null) { - return real; - } - return new Proxy(real, { - get: (source, prop) => { - if (prop === "registerAdapter") { - const register: unknown = Reflect.get(source, prop, source); - if (typeof register !== "function") { - return register; - } - return (routes: readonly string[], adapter: unknown) => { - // Registration is all-or-nothing: one route that is already taken - // would kill every route travelling with it. The taken ones are - // dropped up front; a list that empties that way is "nothing new", - // not a withdrawal. - const listed = adapterRoutes(source) ?? new Set(); - const owned = routes.filter((route) => !listed.has(route)); - if (owned.length === 0) { - return NOOP_HANDLE; - } - const handle: unknown = Reflect.apply(register, source, [ - owned, - adapter, - ]); - captured.push(handle); - return handle; - }; - } - if (prop === "registerConfigurableProviders") { - return () => ({ - dispose: () => { - // Nothing was published, so there is nothing to withdraw. - }, - replace: () => { - // Nothing was published, so there is nothing to replace. - }, - }); - } - if (prop === "registerModelDiscovery") { - return () => { - // Nothing was published, so there is nothing to withdraw. - }; - } - const value: unknown = Reflect.get(source, prop, source); - if (typeof value !== "function") { - return value; - } - const bound = (...args: unknown[]): unknown => - Reflect.apply(value, source, args); - return bound; - }, - }); -}; - -/** - * The real `llm` service, read through the holder's own `get`. - * - * Read off `this` rather than captured, because the shadow is installed on a - * parent scope and every context below it resolves the service through its own - * isolation chain — a captured reference would pin the mount to whichever scope - * happened to build the facade. - * - * @param holder - the context the getter was reached through. - * @returns the service, or `undefined` when the holder cannot resolve it. - */ -const readLlmService = (holder: unknown): unknown => { - if (!isRecord(holder)) { - return undefined; - } - const { get } = holder; - if (typeof get !== "function") { - return undefined; - } - try { - return Reflect.apply(get, holder, ["llm"]); - } catch { - return undefined; - } -}; - -/** - * The scope the host plugin is mounted in, or `undefined` when unsupported. - * - * Both isolated services are load-bearing. `authorization` is what keeps the - * flows from colliding; `settings` is what keeps the mounted instance from - * writing a settings section nobody asked for, which is the seam a check of - * `authorization` alone would miss. - * - * @param ctx - the host context. - * @param captured - collects the handles `registerAdapter` returns. - * @returns the scope to mount into. - */ -const mountScope = ( - ctx: CordisContext, - captured: unknown[] -): CordisContext | undefined => { - let scope: CordisContext | undefined = ctx; - for (const service of HIDDEN_SERVICES) { - if (scope === undefined || typeof scope.isolate !== "function") { - return undefined; - } - scope = scope.isolate(service); - } - if (scope === undefined || typeof scope.extend !== "function") { - return undefined; - } - const shadow = { - get llm(): unknown { - return llmFacade(readLlmService(this), captured); - }, - }; - return scope.extend(shadow); -}; - -/** - * Mount the host's `llm-pi-ai` once, for every internal route this plugin owns. - * - * One mount, not one per route: `apply` declares the whole catalog on every - * call, so a per-route mount is a collision no matter how few routes it carries. - * - * Never throws. A deployment where the host plugin is not loaded, or that - * already declares these routes, keeps its previous behaviour — reported at - * `info`, because both are expected states rather than faults. - * - * @param ctx - host context carrying the loader, the LLM registry and the - * services the mounted plugin needs. - * @returns the disposer withdrawing the mount, or `undefined` when nothing was - * mounted. - */ -/** - * The model ids the deployment declared on one plane route. - * - * `ctx.llm` is the projection of the user's own configuration: `listModels(route)` - * answers what that route advertises, and a route the user wrote is exactly what - * we must not widen. Returns `undefined` when the service cannot answer — the - * caller then falls back to the catalog rather than mounting nothing. - * - * @param ctx - the host context. - * @param route - the plane route (`opencode`, `opencode-go`). - * @returns the declared ids, or `undefined` when unreadable. - */ -/** - * Narrow a route's models to the ones the deployment declared. - * - * The rule the mount needs and the whole reason it can be trusted: a route's - * model list is what a picker shows, so it may never be wider than the user's own - * configuration. `undefined` means "the declaration could not be read", which - * falls back to the full list rather than mounting nothing. - * - * @param models - the protocol's models, from the catalog. - * @param declared - the ids the user wrote, or `undefined` when unreadable. - * @returns `models` when unreadable, otherwise only the declared entries. - */ -/** - * Declared models that speak a protocol nothing in this composition serves. - * - * The case this exists for: the automatic mount stood down (no `llm-pi-ai` in - * this composition, or the early exit with nothing left to mount, where a - * route that did not mount is still missing), so a declared model whose - * protocol differs from its provider's has no route to answer for it. Silence would - * be the worst outcome — the model is in the picker and every call fails — so - * the caller logs these by name and protocol. - * - * A model the catalog does not describe is skipped: it cannot be known to need - * anything else, and guessing would produce a warning that is wrong more often - * than right. - * - * @param declaredByPlane - the ids the deployment declared, per plane route. - * @param served - the `plane:protocol` pairs some route serves. - * @returns one `id (protocol)` entry per unrouted model, sorted for a stable log. - */ -export const unroutedDeclaredModels = ( - declaredByPlane: ReadonlyMap>, - served: ReadonlySet -): string[] => { - const gaps: string[] = []; - for (const [plane, ids] of declaredByPlane) { - const entry = ROUTE_FOR_PLANE_PROTOCOL[plane]; - if (entry === undefined) { - continue; - } - const catalog = - plane === "opencode-go" ? getLiveGoCatalog() : getLiveZenCatalog(); - const byId = new Map(catalog.map((spec) => [spec.id, spec])); - for (const id of ids) { - const spec = byId.get(id); - const protocol = spec === undefined ? undefined : protocolFor(spec); - // A protocol the plane does not carry has no route to miss. - if (protocol === undefined || entry.routes[protocol] === undefined) { - continue; - } - if (served.has(`${plane}:${protocol}`)) { - continue; - } - gaps.push(`${id} (${plane} · ${protocol})`); - } - } - return gaps.toSorted((a, b) => a.localeCompare(b)); -}; - -/** - * Declared models the bundled catalog does not describe. - * - * Two populations, and both are worth naming once at startup: an id NEWER than - * the bundled snapshot (the shim is generated, so it lags the vendor between - * releases) and an id the vendor has marked `deprecated` (the shim drops those by - * design). Either way the model is served from the DEPLOYMENT's configuration — - * the host lists it, the route speaks it — but this plugin can add no metadata: - * no context window, no modalities, no reasoning levels. Saying so is the - * difference between "the plugin is ignoring my model" and "the plugin has never - * seen that id". - * - * @param declaredByPlane - the ids the deployment declared, per plane route. - * @returns one `id (plane)` entry per undescribed model, sorted for a stable log. - */ -export const undescribedDeclaredModels = ( - declaredByPlane: ReadonlyMap> -): string[] => { - const gaps: string[] = []; - for (const [plane, ids] of declaredByPlane) { - const catalog = - plane === "opencode-go" ? getLiveGoCatalog() : getLiveZenCatalog(); - const known = new Set(catalog.map((spec) => spec.id)); - for (const id of ids) { - if (!known.has(id)) { - gaps.push(`${id} (${plane})`); - } - } - } - return gaps.toSorted((a, b) => a.localeCompare(b)); -}; - -export const narrowToDeclared = ( - models: readonly T[], - declared?: ReadonlySet -): readonly T[] => - declared === undefined ? models : models.filter((m) => declared.has(m.id)); - -/** - * The model ids a provider row declares, from the loader's configuration. - * - * A thin projection of {@link declaredProviderModels}, which owns the reading - * itself — `ctx.get` for the loader (property access throws without `inject`), - * `entries()` as the generator it is, and the `models:` array the operator - * wrote. Both callers in this file want ids only; the shared reader answers - * rows because the picker needs their names, so the set is built here rather - * than duplicating the walk. - * - * @param ctx - the host context. - * @param route - the plane route (`opencode`, `opencode-go`). - * @returns the declared ids, or `undefined` when the loader holds no entry for - * that route (a composition this plugin cannot read, not an empty declaration). - */ -const declaredIdsFromLoader = ( - ctx: CordisContext, - route: string -): ReadonlySet | undefined => { - const declared = declaredProviderModels(ctx, route); - return declared === undefined - ? undefined - : new Set(declared.map((model) => model.id)); -}; - -const declaredModelsFor = async ( - ctx: CordisContext, - route: string -): Promise> => { - // The configuration first: it is the operator's own word and it needs no - // credential. `listModels` stays as the fallback for a composition whose - // loader this plugin cannot read. - const declared = declaredIdsFromLoader(ctx, route); - if (declared !== undefined) { - return declared; - } - const listModels = ctx.llm?.listModels; - if (typeof listModels !== "function") { - return new Set(); - } - try { - const listed = await Reflect.apply(listModels, ctx.llm, [route]); - if (!Array.isArray(listed)) { - return new Set(); - } - const ids = new Set(); - for (const entry of listed) { - const id = isRecord(entry) ? entry.id : undefined; - if (typeof id === "string" && id.length > 0) { - ids.add(id); - } - } - // **An empty list means the route declares nothing — and nothing is what - // gets mounted.** An earlier revision read `[]` as "the declaration could - // not be read" and fell back to the whole catalog, on the theory that the - // mounted routes were hidden so the widening was invisible. Measured on a - // live host, that fallback mounted **58 models the deployment never - // declared** across four internal routes: - // - // mounted opencode-responses … with 30 model(s); opencode-anthropic … 18; - // opencode-go-responses … 5; opencode-go-anthropic … 5 - // - // A route's model list is the operator's decision; a fallback that fills it - // with the catalog is precisely the override this plugin must not perform. - // The reason the old comment gave for the fallback — that mounting nothing - // "breaks the protocol split for a deployment that only lacks a key" — no - // longer holds: the adapter decoration now names each model's `api` - // (`decoratePlaneAdapters`), and `dsh-llm-pi-ai` dispatches per model, so a - // declared Responses/Anthropic model reaches its protocol on the route the - // user configured. The internal routes are a legacy path, not the mechanism. - return ids; - } catch { - return new Set(); - } -}; - -export const registerResponsesProvider = async ( - ctx: CordisContext, - config?: ResolvedPluginConfig -): Promise<(() => void) | undefined> => { - const { llm } = ctx; - if (llm === undefined || typeof llm.registerAdapter !== "function") { - return undefined; - } - const declared = declaredRoutes(llm); - // **What the deployment declared, per plane.** The mount serves the protocols a - // declared model needs, with only those models: a route's model list is what the - // picker shows, and the whole protocol catalog there is how models nobody - // selected reached it. Read from the host, which holds the user's configuration. - const declaredByPlane = new Map( - await Promise.all( - ["opencode", "opencode-go"].map( - async (plane) => [plane, await declaredModelsFor(ctx, plane)] as const - ) - ) - ); - for (const [plane, ids] of declaredByPlane) { - if (ids.size === 0) { - // **Say both causes, because this side cannot tell them apart.** The host - // answers `[]` from `listModels` either when the route declares no models or - // when its credential does not resolve (`dsh-llm-pi-ai/lib/index.js:1894` - // returns `[]` on MISSING_CREDENTIAL), and a route the deployment DID - // configure lands in the second case whenever the credential service is not - // readable in this process. Claiming "no models declared" was a lie in - // exactly the state that prompts an operator to check — measured on a second - // instance against a store that holds both keys: the profile declares 16 and - // 11 models, and the plugin announced there were none. - console.info( - `[dsh-opencode-patch] ${plane}: no models could be read for the route — ` + - "it declares none, or its credential is not resolvable in this " + - "process — so no internal route is mounted for it" - ); - } - } - - // The bridge route first. It registers on the real service through this - // plugin's own adapter, so it needs nothing from the wrapper's mount below: - // a deployment that declares every protocol route in its profile still gets - // the Gemini models, which were the one protocol a declared route could - // never name. Its withdrawal joins the disposer this function returns, and - // every early exit below hands it back rather than leaking it. - // The pi the HOST installed, which is the one the wrapper mounts — loaded once - // here rather than declared as a dependency, so a harness bump moves the wire - // implementation without a version skew in between (see `google-bridge.ts`). - const hostPi = await loadHostPi(); - if (!declared.has(GOOGLE_INTERNAL_ROUTE) && hostPi === undefined) { - console.info( - "[dsh-opencode-patch] the Google bridge did not mount: the harness's " + - "@earendil-works/pi-ai could not be resolved from dsh-llm-pi-ai — " + - "the Gemini models will not be offered (every other route is unaffected)" - ); - } - const bridgeHandles: unknown[] = []; - const bridgeHost = await loadPiAiAdapterHost(ctx); - try { - for (const plane of ["opencode", "opencode-go"] as const) { - const route = - plane === "opencode-go" - ? GO_GOOGLE_INTERNAL_ROUTE - : GOOGLE_INTERNAL_ROUTE; - if (declared.has(route) || hostPi === undefined) continue; - const source = planeSource(plane, ctx); - const ids = declaredByPlane.get(plane); - const handle = registerGoogleBridge({ - apiKeyRef: source.apiKeyEnv, - plane, - readOpencodeStore: config?.readOpencodeStore === true, - endpoint: config?.endpoint, - ctx, - host: bridgeHost, - pi: hostPi, - llm: { registerAdapter: llm.registerAdapter.bind(llm) }, - specs: source.catalog.filter( - (spec) => - spec.provider_npm === GOOGLE_SDK && ids?.has(spec.id) === true - ), - }); - if (handle !== undefined) { - bridgeHandles.push(handle); - ctx.logger?.info?.( - "[dsh-opencode-patch] bridged the Gemini models to %s (own adapter)", - route - ); - } - } - } catch (error) { - for (const handle of bridgeHandles) releaseRegistration(handle); - throw error; - } - const bridgeDispose = - bridgeHandles.length === 0 - ? undefined - : () => { - for (const handle of bridgeHandles) releaseRegistration(handle); - }; - // A deployment that already declares a route in its profile keeps it: the user - // hand-picks the models it serves, and mounting over that would take the choice - // away. Deferring is the point — this module exists so a deployment that - // declares NOTHING still works, not to override one that does. - const undescribed = undescribedDeclaredModels(declaredByPlane); - if (undescribed.length > 0) { - // CONSOLE, not `ctx.logger`: cordis's logger only buffers (its default - // exporter pushes to a ring, `cordis/lib/index.js:596-607`) and no host - // package registers a console exporter — so a `logger.info` here reaches the - // inspector's buffer and nobody's terminal. A diagnostic nobody can see is - // not a diagnostic. - console.info( - `[dsh-opencode-patch] ${undescribed.length} declared model(s) are not ` + - `described by the catalog (newer than it, or deprecated): ` + - `${undescribed.join(", ")} — they are served from the deployment's ` + - "configuration, without catalog metadata" - ); - } - /** Protocols some route in this composition serves, as `plane:protocol`. */ - const served = new Set(); - const reportGaps = (): void => { - const gaps = unroutedDeclaredModels(declaredByPlane, served); - if (gaps.length === 0) { - return; - } - console.warn( - `[dsh-opencode-patch] ${gaps.length} declared model(s) speak a protocol no ` + - `route serves: ${gaps.join(", ")} — declare provider routes for them ` + - "(see modelRoutes)" - ); - }; - // A route the deployment declared is served by that declaration. - for (const [plane, entry] of Object.entries(ROUTE_FOR_PLANE_PROTOCOL)) { - for (const [protocol, route] of Object.entries(entry.routes)) { - if (declared.has(route)) { - served.add(`${plane}:${protocol}`); - } - } - } - - const wanted = Object.entries(ROUTE_FOR_PLANE_PROTOCOL).flatMap( - ([plane, entry]) => - Object.entries(entry.routes) - // Self-mounted routes register through the plugin's own adapter, so - // handing their models to the wrapper's mount would only earn its - // protocol table's refusal. - .filter( - ([, route]) => !declared.has(route) && !SELF_MOUNTED_ROUTES.has(route) - ) - .map(([protocol, route]) => ({ - plane, - protocol, - route, - // Per protocol, NOT the plane's base. pi builds each protocol's - // request differently: the OpenAI ones append `/chat/completions` or - // `/responses` to a base that already carries `/v1`, while the - // Anthropic one hands the base straight to the Anthropic SDK, which - // appends `/v1/messages` itself. Mounting the Anthropic route on the - // plane's base therefore asks for `/v1/v1/messages`, which the gateway - // answers with 404 — measured, along with the base that works. - // The plane's own base stays the fallback for a protocol this table - // does not carry, so an unknown protocol degrades to today's - // behaviour rather than to an undefined URL. - baseURL: - baseForProtocol(plane, protocol, config?.endpoint) ?? entry.baseURL, - })) - ); - if (wanted.length === 0) { - console.info( - "[dsh-opencode-patch] every internal route is already declared; leaving them alone" - ); - reportGaps(); - return bridgeDispose; - } - const host = loadPiAi(ctx); - if (host === undefined) { - console.info( - "[dsh-opencode-patch] no loaded llm-pi-ai in this composition; internal routes stay unregistered" - ); - reportGaps(); - return bridgeDispose; - } - const providers: Record = {}; - const mounted: string[] = []; - for (const { plane, protocol, route, baseURL } of wanted) { - // The base URL travels WITH the route, so there is no "unknown plane" case - // to guard: a plane the table does not carry has no route to mount. - const source = planeSource(plane, ctx); - // No empty-catalog guard: each route reads its OWN plane's catalog, so a - // route whose protocol that plane does not carry has no entry in the table - // at all. The guard this replaces existed because every route used the Zen - // catalog, which made every Go route empty. - const declaredIds = declaredByPlane.get(plane); - const models = narrowToDeclared( - sdksForProtocol(protocol).flatMap((sdk) => - modelsForSdk(sdk, source.catalog) - ), - declaredIds - ); - // A protocol no declared model speaks is not mounted at all: an empty route - // is dropped from the picker anyway, and mounting it only to hide it is how - // the exposure happened. - if (models.length === 0) { - // Console, like the boot and diagnostic lines: this is the decision a - // reader needs to verify that nothing the deployment did not declare was - // mounted, and the logger only buffers. - console.info( - `[dsh-opencode-patch] ${route} not mounted: no declared model speaks ${protocol}` - ); - continue; - } - providers[route] = providerProfile( - protocol, - models, - source.apiKeyEnv, - baseURL - ); - served.add(`${plane}:${protocol}`); - mounted.push( - `${route} (${plane} · ${protocol}) with ${models.length} model(s)` - ); - } - if (mounted.length === 0) { - ctx.logger?.info?.( - "[dsh-opencode-patch] no model needs an internal route; nothing to mount" - ); - return bridgeDispose; - } - const captured: unknown[] = []; - const scope = mountScope(ctx, captured); - if (scope === undefined || typeof scope.plugin !== "function") { - ctx.logger?.info?.( - "[dsh-opencode-patch] this host exposes no isolate/plugin scope; internal routes stay unregistered" - ); - return bridgeDispose; - } - // Set once the fiber exists, so every exit below can withdraw what the mount - // did register: a route captured before a startup error is live on the host, - // and a fiber nobody disposes keeps it there across every reload. - let fiber: ReturnType | undefined; - const releaseMount = (): void => { - for (const handle of captured) { - releaseRegistration(handle); - } - captured.length = 0; - const owned = fiber; - fiber = undefined; - void (async () => { - try { - await owned?.dispose?.(); - } catch { - // A disposal failure must not mask the plugin's own teardown. - } - })(); - }; - try { - fiber = scope.plugin(host, { providers }); - // Cordis's `Fiber` is not thenable; `await()` is what waits for the - // lifecycle work and rethrows a startup error. A stand-in may hand over a - // promise instead, so both shapes are settled here. - const settle = fiber?.await; - await Promise.resolve( - typeof settle === "function" ? fiber?.await?.() : fiber - ); - if (captured.length === 0) { - ctx.logger?.info?.( - "[dsh-opencode-patch] the mount registered no route; internal routes stay unregistered" - ); - releaseMount(); - return bridgeDispose; - } - // The line that answers "what actually mounted", which the boot line's - // provider LIST does not: that one is the claimed set, not the mounted set. - console.info( - `[dsh-opencode-patch] mounted ${ - mounted.length === 0 ? "no internal route" : mounted.join("; ") - }` - ); - - return () => { - bridgeDispose?.(); - releaseMount(); - }; - } catch (error) { - releaseMount(); - ctx.logger?.info?.( - "[dsh-opencode-patch] internal routes stay unregistered (%s); routes declared in the profile are unaffected", - error instanceof Error ? error.message : String(error) - ); - return bridgeDispose; - } -}; diff --git a/src/responses-routes.ts b/src/responses-routes.ts deleted file mode 100644 index 82fdfa26..00000000 --- a/src/responses-routes.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * Which wire protocol each gateway model needs, and the internal route that - * serves it. - * - * OpenCode Zen's provider-level SDK is `@ai-sdk/openai-compatible`; models.dev - * names a DIFFERENT SDK per model only when that model needs one, so the - * PRESENCE of `provider.npm` is the signal. Measured against the shipped - * catalog: of its 85 `opencode` models, 28 name nothing (the default), **30 name - * `@ai-sdk/openai`**, **18 name `@ai-sdk/anthropic`**, 8 name `@ai-sdk/google` - * and 1 names `@ai-sdk/mistral` — the only one this plugin does not serve. - * - * So the split is read from the vendor's metadata rather than kept as a list of - * model ids we would have to notice changing. The hand-written list this - * replaced named ONE model, and 54 more were on the wrong side of it. - * - * DSH cannot express "this model speaks a different format" — `llm-pi-ai` - * carries one `api` per ROUTE (`modelProfile`/`modelOverride` both exclude - * `api`), and `llm.registerAdapter` refuses a route that already has an adapter. - * So the format has to be a property of a route, and a model has to be - * dispatched to the route whose `api` already names it. {@link internalRouteFor} - * answers which; the `llm/stream` hook acts on it, and `responses-provider.ts` - * registers the routes — from the plugin, so the user changes nothing. - * - * @module dsh-opencode-patch/responses-routes - */ - -import { findModelSpecOn } from "./models-catalog.ts"; - -/** The SDK whose presence means "this model is served on the Responses API". */ -export const RESPONSES_SDK = "@ai-sdk/openai"; - -/** The SDK whose presence means "this model is served on the Messages API". */ -export const ANTHROPIC_SDK = "@ai-sdk/anthropic"; - -/** The SDK whose models speak pi's `google-generative-ai` protocol. */ -export const GOOGLE_SDK = "@ai-sdk/google"; - -/** The SDK whose models speak pi's `mistral-conversations` protocol. */ -export const MISTRAL_SDK = "@ai-sdk/mistral"; - -/** The SDK whose presence means "this model is served on Mistral's API". */ - -/** Route serving the gateway's Responses-API models. */ -export const RESPONSES_ROUTE = "opencode-responses"; - -/** Route serving the gateway's Messages-API models. */ -export const ANTHROPIC_ROUTE = "opencode-anthropic"; - -/** Route serving the gateway's Mistral-API models. */ - -/** Route serving the Go plane's Responses-API models. */ -export const GO_RESPONSES_ROUTE = "opencode-go-responses"; - -/** Route serving the Go plane's Messages-API models. */ -export const GO_ANTHROPIC_ROUTE = "opencode-go-anthropic"; - -/** - * Route this plugin registers through its own adapter for the gateway's Gemini - * models. - * - * Deliberately not `google`: that is a real provider id a deployment may - * configure for Google AI Studio, and registering it would take the name away - * from whoever configures it next. This one stays internal — the picker lists - * its models under the plane route, and the stream hook dispatches the call - * here, exactly as it does for the protocol routes above. - */ -export const GOOGLE_INTERNAL_ROUTE = "opencode-google"; -/** Google native protocol on the Go subscription. */ -export const GO_GOOGLE_INTERNAL_ROUTE = "opencode-go-google"; - -/** - * The gateway planes: one base URL, one catalog and one credential each. - * - * A plane is a property of the ACCOUNT and the base URL, never of a model — the - * same model id can exist on both planes and be served on a different shape in - * each. Routes are keyed by (plane, protocol) for exactly that reason: an SDK - * says which shape a model needs, never which plane it belongs to. - */ -export const PLANES = { - opencode: "https://opencode.ai/zen/v1", - "opencode-go": "https://opencode.ai/zen/go/v1", -}; - -/** - * The route that serves each (plane, protocol) pair. - * - * The plane's own base route (`opencode`, `opencode-go`) is implicit: it is the - * one a model naming no SDK stays on, and it is not in this table because it is - * never a redirect target — a model already there has nowhere to go. - */ -export const ROUTE_FOR_PLANE_PROTOCOL: Readonly< - Record> }> -> = { - opencode: { - baseURL: PLANES.opencode, - routes: { - "openai-responses": RESPONSES_ROUTE, - "anthropic-messages": ANTHROPIC_ROUTE, - "google-generative-ai": GOOGLE_INTERNAL_ROUTE, - }, - }, - "opencode-go": { - baseURL: PLANES["opencode-go"], - routes: { - "openai-responses": GO_RESPONSES_ROUTE, - "anthropic-messages": GO_ANTHROPIC_ROUTE, - "google-generative-ai": GO_GOOGLE_INTERNAL_ROUTE, - }, - }, -} as const; - -/** - * The pi-ai protocol each SDK a model may name corresponds to, when that - * protocol is not the route's own. Keys are models.dev `provider.npm` values. - * - * Two mechanisms serve the protocols here, and the split is the point: - * - * - `openai-responses` and `anthropic-messages` name protocols the wrapper's - * hand-declared route table implements, so their routes mount through the - * wrapper like every other route this plugin registers for it. - * - `google-generative-ai` names a protocol that table does not carry, so its - * route mounts through this plugin's own adapter instead. The seam cannot - * express it — measured: a hand-declared route may only name a protocol in - * the wrapper's three-entry table, and a catalog route may only be named - * after the catalog provider it reuses, which a later-loading plugin cannot - * claim. `google-bridge.ts` is that adapter, and it is the record of how. - * - */ -export const PROTOCOL_FOR_SDK: Readonly> = { - [RESPONSES_SDK]: "openai-responses", - [ANTHROPIC_SDK]: "anthropic-messages", - [GOOGLE_SDK]: "google-generative-ai", -}; - -/** - * SDKs whose models are deliberately not offered. - * - * Every entry here is a DECISION, not an omission — and the distinction is the - * point. A model naming an SDK that is in neither this set nor - * `PROTOCOL_FOR_SDK` is a GAP: the catalog moved and the code did not, which is - * how `mistral-large-4` went missing from the picker while every gate stayed - * green. `catalog.test.ts` fails on that case and names the SDK. - * - * - `@ai-sdk/mistral` — pi implements `mistral-conversations` and the bridge - * pattern that would serve it is already built and measured for Google, but - * this dialect at the gateway is unmeasured and no entitled key exists to - * measure it. It moves to `PROTOCOL_FOR_SDK` the day that changes. - */ -export const UNSERVED_SDKS: ReadonlySet = new Set([MISTRAL_SDK]); -/** Reserved for protocol/plane combinations without an adapter. */ -export const UNSERVED_PLANE_SDKS: ReadonlySet = new Set(); - -/** - * Routes this plugin registers through its own adapter rather than the - * wrapper's mount, because the wrapper's hand-declared table refuses their - * protocol. `registerResponsesProvider` skips these when building the wrapper's - * providers dict and mounts each one itself. - */ -export const SELF_MOUNTED_ROUTES: ReadonlySet = new Set([ - GOOGLE_INTERNAL_ROUTE, - GO_GOOGLE_INTERNAL_ROUTE, -]); - -/** The route each non-default protocol is served from. */ -/** The Zen plane's rows of {@link ROUTE_FOR_PLANE_PROTOCOL}. */ -export const ROUTE_FOR_PROTOCOL: Readonly> = { - "openai-responses": RESPONSES_ROUTE, - "anthropic-messages": ANTHROPIC_ROUTE, - "google-generative-ai": GOOGLE_INTERNAL_ROUTE, -}; - -/** Every route this plugin registers for itself. */ -export const INTERNAL_ROUTES: readonly string[] = Object.values( - ROUTE_FOR_PLANE_PROTOCOL -).flatMap((plane) => Object.values(plane.routes)); - -/** - * Whether a route id is one this plugin owns and keeps out of the UI. - * - * @param id - the route id to test. - * @returns true when the plugin registered it for a non-default protocol. - */ -export const isInternalRoute = (id: unknown): boolean => - typeof id === "string" && INTERNAL_ROUTES.includes(id); - -/** - * Whether a model can be served at all. - * - * A model naming no SDK speaks the route's own api, and one naming an SDK in - * {@link PROTOCOL_FOR_SDK} has a route to be dispatched to. Anything else names - * a protocol `llm-pi-ai` does not implement, so offering it would be offering a - * model that cannot work — it must not reach a listing the user picks from. - * - * @param providerNpm - the model's `provider.npm` from the catalog, if any. - * @returns true when the model has somewhere to be served from. - */ -export const isServableSdk = (providerNpm?: unknown): boolean => - providerNpm === undefined || - (typeof providerNpm === "string" && - PROTOCOL_FOR_SDK[providerNpm] !== undefined); - -/** - * The route a call should be dispatched through instead, or `undefined` when the - * call is already going to the right place. - * - * Guarded on the SOURCE route as well as the model: a redirected call comes back - * through the same hook with `provider` already set to an internal route, and - * redirecting that again would recurse. - * - * @param provider - the route the caller selected. - * @param model - the model id it selected. - * @param providerNpm - that model's `provider.npm` from the catalog, if any. - * @returns the route to dispatch through, or `undefined` to dispatch as asked. - */ -export const internalRouteFor = ( - provider: unknown, - model: unknown, - providerNpm?: unknown, - /** - * The protocol to route by, overriding what `providerNpm` maps to. - * - * This is `modelRoutes`' entry point into dispatch: a deployment that knows the - * catalog's protocol for a model is wrong can name the right one, and the call - * is handed to the route that speaks it without waiting for the catalog to - * agree. - */ - overrideProtocol?: string -): string | undefined => { - if (typeof provider !== "string" || typeof model !== "string") { - return undefined; - } - // The plane comes from the route the caller selected, and it must BE a plane: - // a redirected call arrives back here with `provider` already set to an - // internal route, which is not a key in this table, so it cannot recurse. - // If a model is Go-only (e.g. `muse-spark-1.3-contributor`), route it on the - // Go plane where its endpoint actually serves it: - let planeKey = provider; - if ( - planeKey === "opencode" && - findModelSpecOn("zen", model) === undefined && - findModelSpecOn("go", model) !== undefined - ) { - planeKey = "opencode-go"; - } - const plane = ROUTE_FOR_PLANE_PROTOCOL[planeKey]; - if (plane === undefined) { - return undefined; - } - // A declared protocol wins outright: it is the deployment saying it knows - // better than the catalog, which is the whole point of `modelRoutes`. - if (overrideProtocol !== undefined) { - return plane.routes[overrideProtocol]; - } - if (providerNpm !== undefined) { - if (typeof providerNpm !== "string") { - return undefined; - } - const protocol = PROTOCOL_FOR_SDK[providerNpm]; - return protocol === undefined ? undefined : plane.routes[protocol]; - } - // A model the catalog carries names its SDK and that is always the answer. - // A model it has never heard of - listed by hand in a deployment's config - - // carries none, and `undefined` reads as the completions DEFAULT: right for a - // model that omits its SDK, wrong for one nobody has described, and the two are - // indistinguishable here. So the id is asked, and only for the families - // whose protocol the vendor fixes by name. - const byId = protocolFromModelId(model); - return byId === undefined ? undefined : plane.routes[byId]; -}; - -/** - * The protocol a model id names, for models no catalog describes. - * - * Only the two families whose protocol the vendor fixes by name: a `gemini-*` - * model is served on the Generative AI path and a `claude-*` one on Messages, and - * neither is ever served as a chat completion. Measured against the live gateway: - * both answer 400 on the completions endpoint and 200 on their own. - * - * Shared by the router and the listing filter on purpose - one rule, so a model - * cannot be routed somewhere the listing said it could not go, or the other way - * round. - * - * @param model - the model id. - * @returns the protocol, or undefined when the id names no family with a fixed one. - */ -export const protocolFromModelId = (model: string): string | undefined => { - if (model.startsWith("gemini-")) { - return "google-generative-ai"; - } - if (model.startsWith("claude-")) { - return "anthropic-messages"; - } - if (model.startsWith("muse-") || model.startsWith("gpt-")) { - return "openai-responses"; - } - return undefined; -}; diff --git a/src/settings-copy.ts b/src/settings-copy.ts index d392a37f..e63d8007 100644 --- a/src/settings-copy.ts +++ b/src/settings-copy.ts @@ -9,9 +9,9 @@ export const en = { description: "OpenCode Zen gateway origin headers, session affinity, free-tier compatibility, and live Go quota display.", - enrichModels: "Enrich model metadata from the OpenCode catalog (default on)", + enrichModels: "Enrich listings from the OpenCode catalog (default on)", enrichModelsHint: - 'Fills context limits, modalities and thinking levels from the OpenCode catalog (models.opencode.ai — the same registry models.dev serves) into the model info, and lets the gateway\'s /models listing carry the full catalog. It does NOT add models to the picker — the picker shows only what you declared; use "Check catalog" above for that. Off leaves both the listing and the model info as the gateway sent them.', + "Lets the gateway's /models listing carry the full OpenCode catalog (models.opencode.ai — the same registry models.dev serves) with names, context limits and modalities, and offers that catalog to Settings' model discovery. It does NOT add models to the picker — the picker shows only what you declared; use \"Check catalog\" above for that. A declared model's own info (context, modalities, thinking levels) always comes from your model row and the catalog, whatever this says. Off leaves the gateway listing as the gateway sent it and offers no catalog to discovery.", endpoint: "API Endpoint", endpointAuto: "Automatic (same as Modern)", endpointHint: @@ -40,7 +40,7 @@ export const en = { keySourceAuto: "Automatic", keySourceConfigured: "Declared key first", keySourceHint: - "Which key wins when several resolve. Automatic takes the declared key, then one captured from a live request; the other two promote one side.", + "Which key wins when several resolve. Automatic takes a literal key, then one captured from a live request, then the declared reference; Declared key first puts the reference ahead of the captured key, Live request first puts captured keys first.", keySourceRequest: "Live request first", readOpencodeStore: "Read the OpenCode installation's key (default off)", readOpencodeStoreHint: @@ -180,9 +180,9 @@ export const en = { export const zh: Record = { description: "OpenCode Zen 网关来源头恢复、会话保持、免费模型兼容与 Go 实时额度显示。", - enrichModels: "用 OpenCode 官方目录补全模型信息(默认开启)", + enrichModels: "用 OpenCode 官方目录补全列表(默认开启)", enrichModelsHint: - "把 OpenCode 官方目录(models.opencode.ai,与 models.dev 是同一份注册表)里的上下文上限、模态与思考档位补进模型信息,并让网关 /models 列表带上完整目录。它不会往选择器里添加模型——选择器只显示你声明的那些,要添加请用上面的「检查目录」。关闭后网关列表与模型信息都保持原样。", + "让网关 /models 列表带上 OpenCode 官方目录(models.opencode.ai,与 models.dev 是同一份注册表)里的完整目录,含名称、上下文上限与模态,并把这份目录提供给设置页的模型发现。它不会往选择器里添加模型——选择器只显示你声明的那些,要添加请用上面的「检查目录」。已声明模型自身的信息(上下文、模态、思考档位)始终来自你的模型行与目录,与此开关无关。关闭后网关列表保持原样,也不向模型发现提供目录。", injectCoreTools: "自动补全核心工具(默认开启)", injectCoreToolsHint: "在免费模型请求中自动注入 read 和 bash 工具声明以满足网关校验。留空沿用默认值。", @@ -211,7 +211,7 @@ export const zh: Record = { keySourceAuto: "自动", keySourceConfigured: "优先已声明密钥", keySourceHint: - "多个来源都可用时以哪个为准。自动先取已声明密钥、再取捕获密钥;另两项各优先一侧。", + "多个来源都可用时以哪个为准。自动先取字面密钥,再取实时请求捕获的密钥,最后取声明的引用;优先已声明密钥把引用排在捕获密钥之前,优先实时请求把捕获密钥排在最前。", keySourceRequest: "优先实时请求", readOpencodeStore: "读取 OpenCode 安装的密钥(默认关闭)", readOpencodeStoreHint: diff --git a/src/settings-fields.ts b/src/settings-fields.ts index f21e9ad1..9a3946df 100644 --- a/src/settings-fields.ts +++ b/src/settings-fields.ts @@ -83,28 +83,13 @@ export const CONFIG_ONLY_FIELDS: readonly { }, { field: "providers", - reason: "route ids to intercept; the default covers the documented routes", + reason: + "route ids whose turns are decorated; both planes are always included, so only a relay route needs adding", }, { field: "gatewayUrls", reason: "URL substrings; only a mirror or relay needs to change them", }, - { - field: "maxContextWindow", - reason: - "context ceiling in tokens; an override for deployments tuning context limits", - }, - { - field: "modelOverrides", - reason: - "per-model overrides for contextWindow, maxTokens, and metadata in YAML config", - }, - { - field: "modelRoutes", - reason: - "per-model wire routing (api and baseURL); it overrides what the catalog " + - "computed, which is a deployment concern rather than a card control", - }, { field: "usageBaseURL", reason: "endpoint override; auto-discovered from the composition", diff --git a/src/stream-hook.ts b/src/stream-hook.ts index 340dfb95..907d54be 100644 --- a/src/stream-hook.ts +++ b/src/stream-hook.ts @@ -21,12 +21,6 @@ import { findModelSpec, findModelSpecOn, } from "./models-catalog.ts"; -import { isRouteRegistered, isServableModel } from "./models-discovery.ts"; -import { - internalRouteFor, - UNSERVED_PLANE_SDKS, - GO_GOOGLE_INTERNAL_ROUTE, -} from "./responses-routes.ts"; import { recordTurnUsage } from "./session-cost.ts"; import { fallbackSessionId, @@ -36,7 +30,6 @@ import { } from "./session.ts"; import type { ActiveTurnState } from "./turn-store.ts"; import { withStore } from "./turn-store.ts"; -import { googleHistoryFor, googleReplayStream } from "./utils/google-replay.ts"; import { isAsyncIterableLike, isRecord } from "./utils/guards.ts"; interface StreamOptions { @@ -72,12 +65,6 @@ type StreamHandler = (options: unknown, next: () => unknown) => unknown; * string — so the full path, username included, would be sent as the project * name. This header is for attribution, not for a filesystem layout. */ -/** - * Models already reported as unservable, so the report happens once rather than on - * every request that names one. - */ -const warnedUnservable = new Set(); - /** Markers whose presence makes a directory a repository root, either kind. */ const VCS_MARKERS = [".git", ".hg"] as const; @@ -122,9 +109,6 @@ export const projectNameFrom = (cwd: string): string | undefined => { export const createStreamHook = ( ctx: DebugContext & { - llm?: { - stream?: (options: unknown) => unknown; - }; logger?: { info?: (msg: string, ...args: unknown[]) => void; }; @@ -147,118 +131,6 @@ export const createStreamHook = ( return next(); } - // A model this plugin cannot serve, named by hand - once per pair, because - // this hook runs on every request. - // - // The message states the reason it COMPUTED and nothing it did not. An - // earlier version blamed the Zen free tier for every unservable id, which was - // wrong twice over: for an `opencode-go` request that plane has no free tier - // at all, and the filter's reasons here are about SHAPE - an SDK this plane - // has no route for. The filter did spend a round excluding free-tier ids, and - // this message computed that half too; the exclusion was withdrawn again when - // the decision was made to offer them (a refused free id answers with the - // gateway's own words, which is more use than an empty picker), so the shape - // reasons below are what remains. A diagnostic that asserts a cause it never - // checked is worse than none - it sends the reader to the wrong subsystem, - // which is exactly what it did. - if ( - typeof options.model === "string" && - options.model.length > 0 && - !isServableModel(options.model, providerKey) && - !warnedUnservable.has(`${providerKey}/${options.model}`) - ) { - warnedUnservable.add(`${providerKey}/${options.model}`); - const plane = catalogPlaneForRoute(providerKey); - const spec = findModelSpecOn(plane, options.model); - const sdk = spec?.provider_npm; - // Checked in the order the filter itself applies them, so the reason named - // is the one that actually decided it. Naming the shape for a model that - // was filtered on its tier sends the reader to the routing table for an - // entitlement problem. - let why: string; - if (sdk === undefined) { - why = "it names no SDK this plane has a route for"; - } else if (UNSERVED_PLANE_SDKS.has(`${providerKey} + ${sdk}`)) { - why = `its catalog SDK \`${sdk}\` is not served on this plane`; - } else { - why = `its catalog SDK \`${sdk}\` names a protocol this plane has no route for`; - } - console.warn( - `[dsh-opencode-patch] ${providerKey}/${options.model} cannot be served: ${why}`, - "the picker does not offer it for the same reason" - ); - } - - // A model the gateway serves on /responses cannot be dispatched through a - // chat-completions route. Rather than translating the protocol at the - // transport, hand the call to the route whose `api` already says - // `openai-responses`: the adapter then speaks the format natively and - // nothing about the response has to be rewritten. Returning here rather than - // continuing means the redirected call does the session/debug/turn-state - // work exactly once — its own pass through this hook. - const redirect = internalRouteFor( - providerKey, - options.model, - // The vendor's own statement of the split: a model naming a different SDK - // than the route's is served on a different API. Read from the catalog - // rather than from a list we would have to notice changing. - // - // Read from the plane the REQUEST is on, not from a Go-first lookup. The - // two planes declare different SDKs for the same id — models.dev's - // `opencode-go` names `@ai-sdk/anthropic` for `qwen3.8-max`, - // `minimax-m2.7` and `minimax-m3` while its `opencode` names the default — - // so a single lookup answered a Zen question with Go data and sent those - // three to `/messages`. Measured: the Zen gateway serves `qwen3.8-max` and - // `minimax-m3` on `/chat/completions` and answers - // `400 ModelProtocolUnsupported` on `/messages`. The route decides the - // plane, because the route is what the user configured. - typeof options.model === "string" - ? ( - findModelSpecOn(catalogPlaneForRoute(providerKey), options.model) ?? - findModelSpec(options.model) - )?.provider_npm - : undefined, - // A route the deployment declared beats the catalog's own answer. The - // catalog is a snapshot; the deployment is the one place that can say it is - // wrong about a model. - typeof options.model === "string" - ? config.modelRoutes[options.model]?.api - : undefined - ); - // Take the call over only when the target route is really registered. - // Without this, a layer that failed to load would replace the gateway's own - // error with a "no adapter for provider" one, which is harder to act on and - // points at the wrong thing. Asked of the UNFILTERED registry — the route is - // deliberately absent from every listing a user sees. - const isAvailable = (routeId: string): boolean => { - const llm: unknown = ctx.llm; - if ( - isRecord(llm) && - "adapters" in llm && - llm.adapters instanceof Map && - llm.adapters.has(routeId) - ) { - return true; - } - return isRouteRegistered(routeId); - }; - if ( - redirect !== undefined && - typeof ctx.llm?.stream === "function" && - isAvailable(redirect) - ) { - if (redirect === GO_GOOGLE_INTERNAL_ROUTE) { - const stream = ctx.llm.stream({ - ...options, - provider: redirect, - messages: googleHistoryFor(options.messages, providerKey, redirect), - }); - return isAsyncIterableLike(stream) - ? googleReplayStream(stream, redirect, providerKey) - : stream; - } - return ctx.llm.stream({ ...options, provider: redirect }); - } const sessionProp: unknown = options.sessionId; // The session is looked up FIRST: its `createdAt` is what lets the derived // id carry the conversation's real creation time instead of hash bits, and @@ -345,7 +217,7 @@ export const createStreamHook = ( } const modelProp: unknown = options.model; const modelName = typeof modelProp === "string" ? modelProp : "unknown"; - // The ROUTE's plane first, as the dispatch lookup above does: the two planes + // The ROUTE's plane first: the two planes // price some shared ids differently (deepseek-v4-pro is 0.66/1.98 on Go and // 1.74/3.48 on Zen), and a Go-first lookup recorded every Zen turn on those // at the Go rate. The Go-first lookup is the fallback for a model neither diff --git a/src/utils/google-replay.ts b/src/utils/google-replay.ts deleted file mode 100644 index 05f8c222..00000000 --- a/src/utils/google-replay.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { isRecord } from "./guards.ts"; - -/** Translate only adapter-owned Google replay identity across a route redirect. */ -export const googleReplayFor = ( - state: unknown, - from: string, - to: string -): unknown => { - if ( - !isRecord(state) || - !isRecord(state.response) || - state.response.kind !== "pi-ai" || - state.response.api !== "google-generative-ai" || - state.response.provider !== from - ) - return state; - return { ...state, response: { ...state.response, provider: to } }; -}; - -/** The agent stores the public provider; the native adapter needs its internal identity. */ -export const googleHistoryFor = ( - messages: unknown, - from: string, - to: string -): unknown => { - if (!Array.isArray(messages)) return messages; - return messages.map((message: unknown) => { - if ( - !isRecord(message) || - message.role !== "assistant" || - !isRecord(message.source) || - message.source.provider !== from - ) - return message; - const replay = googleReplayFor(message.source.replayState, from, to); - if (replay === message.source.replayState) return message; - return { - ...message, - source: { ...message.source, provider: to, replayState: replay }, - }; - }); -}; - -/** - * Keep persisted replay consistent with the public provider recorded by the agent. - * @yields {unknown} Stream chunks with public replay identity and no undefined fields added. - */ -export async function* googleReplayStream( - stream: AsyncIterable, - from: string, - to: string -): AsyncIterable { - for await (const chunk of stream) { - if (isRecord(chunk) && chunk.type === "finish") { - const replay = googleReplayFor(chunk.replayState, from, to); - yield replay === chunk.replayState - ? chunk - : { ...chunk, replayState: replay }; - } else yield chunk; - } -} diff --git a/src/utils/model-metadata.ts b/src/utils/model-metadata.ts new file mode 100644 index 00000000..c86bd7af --- /dev/null +++ b/src/utils/model-metadata.ts @@ -0,0 +1,311 @@ +/** + * A model's reasoning levels, in both of pi's vocabularies at once. + * + * Two vocabularies mean opposite things by `null` (docs/improvement-plan.md, + * "D1 representation"), and a model must say the same thing in both: + * + * - pi's `thinkingLevelMap` on the model object: a level mapped to `null` is + * NOT offered, and an absent `off` key is what offers Off and lets the + * protocol send its explicit disable (`off: null` hides Off); + * - a profile's `reasoningEfforts` dict: an offered level maps to its wire + * value, and `off: null` means "Off is offered, send nothing". + * + * A string `off` means the same in both: Off is offered, and that string is + * the disable the protocol sends. + * + * The picker reads the efforts pi derives from the map + * (`getSupportedThinkingLevels`), so one rule here decides both what the + * composer offers and what dispatch accepts. A model whose reasoning is only a + * switch is offered as Off / On (D1), Off only where its protocol can actually + * disable reasoning. + * + * @module dsh-opencode-patch/utils/model-metadata + */ + +import { type Protocol, protocolForSdk } from "./protocols.ts"; + +/** pi's thinking levels, in escalation order, `off` first. */ +export const PI_LEVELS = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +/** One of {@link PI_LEVELS}. */ +export type PiLevel = (typeof PI_LEVELS)[number]; + +/** + * The name pi-ai's adapter shows for a level: `Off`, otherwise the level + * capitalized. + * + * @param level - the pi level. + */ +export const effortName = (level: string): string => + level === "off" ? "Off" : `${level.charAt(0).toUpperCase()}${level.slice(1)}`; + +/** The completions dialects that send an explicit disable when Off is offered. */ +const DISABLING_THINKING_FORMATS: ReadonlySet = new Set([ + "deepseek", + "openrouter", + "string-thinking", + "together", +]); + +/** + * Whether a protocol can turn reasoning off, rather than merely send nothing. + * + * Read from pi's own request builders: Anthropic sends `thinking.disabled`, + * Responses an `effort` of `none`, Google its disabled thinking config, and + * completions only in the dialects that carry a disable + * (`openai-completions.js:667-711`). + * + * @param protocol - the model's protocol. + * @param compat - the model's resolved compat block, if any. + */ +export const canDisableReasoning = ( + protocol: Protocol, + compat?: Readonly> +): boolean => { + if (protocol !== "openai-completions") { + return true; + } + const format = compat?.thinkingFormat; + return typeof format === "string" && DISABLING_THINKING_FORMATS.has(format); +}; + +/** The slice of a catalog spec the reasoning rule reads. */ +export interface ReasoningSpec { + readonly reasoning?: boolean; + readonly reasoning_options?: readonly { + readonly type: string; + readonly values?: readonly string[]; + }[]; +} + +/** One model's reasoning, in both vocabularies and the picker's. */ +export interface ModelReasoning { + /** pi's map for the model object. */ + readonly thinkingLevelMap: Readonly>; + /** The profile dict that says the same thing. */ + readonly reasoningEfforts: Readonly>; + /** The picker's efforts, `Off` first when offered. */ + readonly efforts: readonly { readonly id: string; readonly name: string }[]; + /** Each offered id's display name. */ + readonly effortNames: Readonly>; +} + +/** The first effort list with values, the one {@link reasoningFor} reads. */ +const effortValuesOf = (spec: ReasoningSpec): readonly string[] | undefined => + spec.reasoning_options?.find( + (option) => option.type === "effort" && (option.values?.length ?? 0) > 0 + )?.values; + +/** Google's discrete thinking levels (`thinkingConfig.thinkingLevel`). */ +const GOOGLE_THINKING_LEVELS: ReadonlySet = new Set([ + "minimal", + "low", + "medium", + "high", +]); + +/** + * Whether the catalog describes a model's reasoning as Google's discrete + * thinking levels: a reasoning model served on the Google protocol whose + * effort list names only `minimal`/`low`/`medium`/`high` (D16). + * + * Google's two thinking controls are not interchangeable: a model that thinks + * in levels refuses a token `thinkingBudget` with 400 INVALID_ARGUMENT + * ("Thinking budget is not supported for this model. Use thinking level + * instead."), measured live on Go `gemini-4-argon`. pi 1.1.0 decides the + * control by an id pattern (`usesGoogleThinkingLevel`), which misses any id + * it was not written for; this answers the same question from the catalog. + * + * The first effort list with values decides, the one {@link reasoningFor} + * turns into the model's levels: a `budget_tokens` option beside it says a + * budget is ALSO accepted, which leaves the level request valid, so it does + * not change the answer. + * + * @param spec - the catalog spec. + */ +export const usesCatalogThinkingLevels = ( + spec: ReasoningSpec & { readonly provider_npm?: string } +): boolean => { + if ( + spec.reasoning !== true || + protocolForSdk(spec.provider_npm) !== "google-generative-ai" + ) { + return false; + } + const values = effortValuesOf(spec); + return ( + values !== undefined && + values.every((value) => GOOGLE_THINKING_LEVELS.has(value)) + ); +}; + +/** The values in a catalog effort list that name an explicit disable. */ +const DISABLE_VALUES: readonly string[] = ["none", "off"]; + +/** + * The levels a catalog spec offers, whether it is a bare switch, and the + * disable value its effort list names, if any. + */ +const offeredLevels = ( + spec: ReasoningSpec +): { levels: PiLevel[]; toggle: boolean; disable?: string } => { + const options = spec.reasoning_options ?? []; + const values = effortValuesOf(spec); + if (values !== undefined) { + const levels = PI_LEVELS.filter( + (level) => level !== "off" && values.includes(level) + ); + if (levels.length > 0) { + const disable = DISABLE_VALUES.find((value) => values.includes(value)); + return { + levels, + toggle: false, + ...(disable === undefined ? {} : { disable }), + }; + } + } + if (options.some((option) => option.type === "toggle")) { + return { levels: ["high"], toggle: true }; + } + return { levels: ["low", "medium", "high"], toggle: false }; +}; + +/** + * The reasoning a catalog spec declares, for one protocol. + * + * - An `effort` list offers exactly its pi levels. A `none` (or `off`) in it + * is the model's explicit disable: on completions it offers Off with that + * value as Off's WIRE value, the way pi's own entries carry it + * (`thinkingLevelMap.off = "none"`), because pi's completions path sends a + * string `off` as `reasoning_effort`, both when Off is picked and when no + * effort is (`openai-completions.js:717-721`). On Responses the list's + * `none` does not offer Off yet: pi 1.1.0's own entries disagree there + * (gpt-5.x pin `off: null`, gpt-6 carry `"none"`), and a string `off` + * becomes the request sent when no effort is selected + * (`openai-responses.js:278-280`), so that is the owner's call, not this + * rule's. + * - A bare `toggle` offers `high` as "On", and Off where the protocol can + * disable (D1). + * - Any other reasoning model gets `low`/`medium`/`high`. + * + * @param spec - the catalog spec. + * @param protocol - the protocol the model is served on. + * @param compat - the model's resolved compat block, if any. + * @returns the reasoning, or `undefined` for a model that declares none. + */ +export const reasoningFor = ( + spec: ReasoningSpec, + protocol: Protocol, + compat?: Readonly> +): ModelReasoning | undefined => { + if (spec.reasoning !== true) { + return undefined; + } + const { disable, levels, toggle } = offeredLevels(spec); + const off = toggle && canDisableReasoning(protocol, compat); + // The explicit disable the effort list names, sent as Off's wire value. + const offWire = + disable !== undefined && protocol === "openai-completions" + ? disable + : undefined; + const thinkingLevelMap: Record = off + ? {} + : { off: offWire ?? null }; + for (const level of PI_LEVELS) { + if (level !== "off") { + thinkingLevelMap[level] = levels.includes(level) ? level : null; + } + } + // Off offered as "send nothing" (a bare toggle), as a wire value, or not. + const offEffort: string | null | undefined = off ? null : offWire; + const reasoningEfforts: Record = + offEffort === undefined ? {} : { off: offEffort }; + const efforts: { id: string; name: string }[] = + off || offWire !== undefined ? [{ id: "off", name: "Off" }] : []; + for (const level of levels) { + reasoningEfforts[level] = level; + efforts.push({ id: level, name: toggle ? "On" : effortName(level) }); + } + return { + effortNames: Object.fromEntries( + efforts.map((effort) => [effort.id, effort.name]) + ), + efforts, + reasoningEfforts, + thinkingLevelMap, + }; +}; + +/** What {@link piReasoningFromEfforts} answers. */ +export type PiReasoning = + | { + readonly reasoning: boolean; + readonly thinkingLevelMap?: Readonly>; + } + | { readonly error: string }; + +/** + * A profile's declared `reasoningEfforts`, translated the way pi-ai's own + * `resolveModelReasoning` translates it (`llm-pi-ai/src/catalog.ts:681-721`), + * with its messages, so a malformed dict reads the same on a plane. + * + * @param model - the model id, for the message. + * @param route - the route, for the message. + * @param efforts - the declared dict, or `false` for a non-reasoning model. + * @returns the reasoning fields, or the error pi-ai would have raised. + */ +export const piReasoningFromEfforts = ( + model: string, + route: string, + efforts: Readonly> | false | null +): PiReasoning => { + const fail = (detail: string): PiReasoning => ({ + error: `llm-pi-ai: provider "${route}" model "${model}" ${detail}`, + }); + if (efforts === false) { + return { reasoning: false }; + } + if (efforts === null || Object.keys(efforts).length === 0) { + return fail( + "has an empty reasoningEfforts; declare the offered levels, set false for a non-reasoning model, or omit the field to keep the installed catalog's capability" + ); + } + const declared = PI_LEVELS.flatMap((level) => { + const wire = efforts[level]; + return wire === undefined ? [] : [[level, wire] as const]; + }); + for (const [level, wire] of declared) { + if (wire === null) { + if (level !== "off") { + return fail( + `reasoningEfforts.${level} needs the wire value dispatch should send; only "off" may leave it empty` + ); + } + } else if (wire.length === 0) { + return fail(`reasoningEfforts.${level} must not be an empty string`); + } + } + if (!declared.some(([level]) => level !== "off")) { + return fail( + 'reasoningEfforts offers no level beyond "off"; declare a thinking level, or set reasoningEfforts to false for a non-reasoning model' + ); + } + const map: Record = {}; + for (const level of PI_LEVELS) { + const wire = efforts[level]; + if (wire === undefined) { + map[level] = null; + } else if (wire !== null) { + map[level] = wire; + } + } + return { reasoning: true, thinkingLevelMap: map }; +}; diff --git a/src/utils/plane-model.ts b/src/utils/plane-model.ts new file mode 100644 index 00000000..b21b6802 --- /dev/null +++ b/src/utils/plane-model.ts @@ -0,0 +1,443 @@ +/** + * One plane model's pi object, from the sources that describe it, in one + * precedence (docs/improvement-plan.md, "The design"). + * + * | field | first source that has it | + * | :-- | :-- | + * | protocol | the catalog SDK ({@link protocolForSdk}) > pi's entry > the route's `api` > completions (D16) | + * | name, context, max tokens, modalities | the user's row > pi-ai's `modelOverrides` > catalog > pi > defaults (D17) | + * | cost | catalog > pi > zero | + * | reasoning | the user's `reasoningEfforts` > the override's > the catalog ({@link reasoningFor}, D1) > pi > none | + * | baseUrl | the route's `baseURL`, shaped > the endpoint family ({@link baseUrlFor}) | + * + * A route-level `api` never reclassifies an id the catalog or pi describes: + * the plane serves every protocol at once, so the route's `api` only says what + * an id nobody describes speaks. + * + * Pure: the catalog spec, pi's entry and the family map come in as values, and + * nothing here throws — a malformed row comes back as `error`, in pi-ai's + * words, for the profile to record against the model. + * + * @module dsh-opencode-patch/utils/plane-model + */ + +import { isRecord } from "./guards.ts"; +import { + piReasoningFromEfforts, + reasoningFor, + type ReasoningSpec, +} from "./model-metadata.ts"; +import { + compatProtocols, + FAMILY_SDK_KEY, + isProtocol, + LEGACY_BASE, + MODERN_FALLBACK, + OFFERED_COMPAT, + type PlaneRoute, + type Protocol, + protocolForSdk, +} from "./protocols.ts"; + +/** The endpoint family a plane sends to; `auto` is `modern`. */ +export type EndpointChoice = "auto" | "legacy" | "modern"; + +/** The catalog spec fields this module reads (`CatalogModelSpec`'s shape). */ +export interface PlaneCatalogSpec extends ReasoningSpec { + readonly id: string; + readonly name?: string; + readonly context_window?: number; + readonly max_output_tokens?: number; + readonly input_modalities?: readonly string[]; + readonly provider_npm?: string; + readonly cost?: { + readonly input?: number; + readonly output?: number; + readonly cache_read?: number; + readonly cache_write?: number; + }; +} + +/** pi's installed model, as far as this module reads it; the rest is spread. */ +export interface PlanePiModel { + readonly id: string; + readonly api?: string; + readonly baseUrl?: string; + readonly name?: string; + readonly contextWindow?: number; + readonly maxTokens?: number; + readonly input?: readonly string[]; + readonly reasoning?: boolean; + readonly thinkingLevelMap?: Readonly>; + readonly compat?: Readonly>; + readonly cost?: Readonly>; + readonly [key: string]: unknown; +} + +/** One `models` entry or `modelOverrides` value, after pi-ai's `Config`. */ +export interface PlaneModelEntry { + readonly id?: unknown; + readonly name?: unknown; + readonly contextWindow?: unknown; + readonly maxTokens?: unknown; + readonly input?: unknown; + readonly reasoningEfforts?: unknown; + readonly compat?: unknown; +} + +/** What {@link planeModelFor} takes. */ +export interface PlaneModelInput { + readonly route: PlaneRoute; + readonly entry: PlaneModelEntry; + readonly override?: PlaneModelEntry; + readonly spec?: PlaneCatalogSpec; + readonly pi?: PlanePiModel; + readonly routeApi?: string; + readonly routeBaseURL?: string; + readonly routeCompat?: Readonly>; + readonly endpoint: EndpointChoice; + /** The catalog's family map for this plane (`OPENCODE_FAMILY_API.zen|go`). */ + readonly familyApi: Readonly>; + readonly defaults: { + readonly contextWindow: number; + readonly maxTokens: number; + readonly input: readonly string[]; + }; +} + +/** A model {@link planeModelFor} could build. */ +export interface PlaneModelServed { + /** The pi model object. */ + readonly model: Readonly> & { + readonly id: string; + readonly api: Protocol; + readonly baseUrl: string; + }; + /** Set only when the user's row or override names `maxTokens`. */ + readonly configuredMaxTokens?: number; + /** Display names for the efforts, when the catalog decided them. */ + readonly effortNames?: Readonly>; + readonly error?: undefined; +} + +/** What {@link planeModelFor} answers: the model, or pi-ai's message for a row it cannot serve. */ +export type PlaneModelResult = + | PlaneModelServed + | { + readonly error: string; + readonly model?: undefined; + readonly configuredMaxTokens?: undefined; + readonly effortNames?: undefined; + }; + +const NO_COST = { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 }; + +/** The modalities a model may declare. */ +const MODALITIES: ReadonlySet = new Set(["text", "image"]); + +/** A declared modality list, or `undefined` when it states none. */ +const declaredInput = (value: unknown): string[] | undefined => + Array.isArray(value) && value.length > 0 + ? value.filter((item): item is string => typeof item === "string") + : undefined; + +/** The catalog's modalities, narrowed to what a request can carry. */ +const catalogInput = (spec: PlaneCatalogSpec): string[] | undefined => { + const input = spec.input_modalities?.filter((item) => MODALITIES.has(item)); + return input === undefined || input.length === 0 ? undefined : input; +}; + +const isWholePositive = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value > 0; + +/** The opencode.ai path of a URL, or `undefined` for another host. */ +const vendorPathOf = (url: string): string | undefined => { + try { + const parsed = new URL(url); + return parsed.hostname === "opencode.ai" ? parsed.pathname : undefined; + } catch { + return undefined; + } +}; + +const trimSlashes = (url: string): string => url.replace(/\/+$/u, ""); + +/** The Anthropic SDK appends `/v1/messages`, so its base drops a trailing `/v1`. */ +const forProtocol = (protocol: Protocol, base: string): string => + protocol === "anthropic-messages" ? base.replace(/\/v1$/u, "") : base; + +/** + * The base one model's requests go to. + * + * 1. A route-level `baseURL` on opencode.ai selects a family: a `/zen…` path + * the legacy one, `/inference(/go)(/openai)/v1` the modern one. + * 2. Any other route-level `baseURL` is the user's endpoint, used as written + * (trailing slashes trimmed, and a trailing `/v1` dropped for Anthropic). + * 3. Otherwise the `endpoint` choice; `auto` is `modern`. + * 4. Modern: the catalog's family map for the model's SDK, else for the + * protocol's family, else the measured fallback. + * 5. Legacy: pi's own base when pi files the model under this protocol on a + * `/zen` root, else the fixed table — Go's Google cell included. + * + * @returns the base URL. + */ +export const baseUrlFor = ( + route: PlaneRoute, + protocol: Protocol, + sdk: string | undefined, + endpoint: EndpointChoice, + familyApi: Readonly>, + routeBaseURL?: string, + pi?: PlanePiModel +): string => { + let family: "legacy" | "modern" = endpoint === "legacy" ? "legacy" : "modern"; + if (routeBaseURL !== undefined && routeBaseURL.length > 0) { + const path = vendorPathOf(routeBaseURL); + if (path?.startsWith("/zen") === true) { + family = "legacy"; + } else if ( + path !== undefined && + /^\/inference(?:\/go)?(?:\/openai)?\/v1\/?$/u.test(path) + ) { + family = "modern"; + } else { + return forProtocol(protocol, trimSlashes(routeBaseURL)); + } + } + if (family === "legacy") { + const piPath = + pi?.api === protocol && typeof pi.baseUrl === "string" + ? vendorPathOf(pi.baseUrl) + : undefined; + return piPath?.startsWith("/zen") === true && pi?.baseUrl !== undefined + ? pi.baseUrl + : LEGACY_BASE[route][protocol]; + } + const declared = + (sdk === undefined ? undefined : familyApi[sdk]) ?? + familyApi[FAMILY_SDK_KEY[protocol]] ?? + MODERN_FALLBACK[route][protocol]; + return forProtocol(protocol, declared); +}; + +/** The compat entries a block actually set: an empty dict states nothing. */ +const configuredCompat = (compat: unknown): [string, unknown][] => + isRecord(compat) + ? Object.entries(compat).filter( + ([, value]) => !(isRecord(value) && Object.keys(value).length === 0) + ) + : []; + +/** + * The compat block, as pi-ai's `resolveModelCompat` builds it + * (`catalog.ts:738-771`): route switches the protocol takes, then the model's + * own (one the protocol does not take is an error), over pi's entry when pi + * files the model under the same protocol. + */ +const compatFor = ( + route: string, + id: string, + protocol: Protocol, + entryCompat: unknown, + routeCompat: unknown, + pi: PlanePiModel | undefined +): { compat?: Readonly>; error?: string } => { + const offered = OFFERED_COMPAT[protocol]; + const configured: Record = {}; + for (const [field, value] of configuredCompat(routeCompat)) { + if (offered.has(field)) { + configured[field] = value; + } + } + for (const [field, value] of configuredCompat(entryCompat)) { + if (!offered.has(field)) { + const fields = [...offered]; + return { + error: `llm-pi-ai: provider "${route}" model "${id}" sets compat "${field}", but its api is "${protocol}", which does not take it; that switch exists on ${compatProtocols(field).join(", ")}, and "${protocol}" offers ${fields.length === 0 ? "no configurable compat" : fields.join(", ")}`, + }; + } + configured[field] = value; + } + const inherited = pi?.api === protocol ? pi.compat : undefined; + if (Object.keys(configured).length === 0) { + return inherited === undefined ? {} : { compat: inherited }; + } + return { compat: { ...inherited, ...configured } }; +}; + +/** + * A declared `reasoningEfforts`, as `piReasoningFromEfforts` reads it: `false`, + * a dict of wire values (anything else in it is no value), or `null` for a + * value that is neither — which pi-ai reports as an empty declaration. + */ +const effortsDictOf = ( + value: unknown +): Readonly> | false | null => { + if (value === false) { + return false; + } + if (!isRecord(value)) { + return null; + } + const dict: Record = {}; + for (const [level, wire] of Object.entries(value)) { + if (typeof wire === "string" || wire === null) { + dict[level] = wire; + } + } + return dict; +}; + +/** The protocol an id speaks (D16). */ +const protocolOf = ( + spec: PlaneCatalogSpec | undefined, + pi: PlanePiModel | undefined, + routeApi: string | undefined +): Protocol => { + const fromCatalog = + spec === undefined ? undefined : protocolForSdk(spec.provider_npm); + if (fromCatalog !== undefined) { + return fromCatalog; + } + if (isProtocol(pi?.api)) { + return pi.api; + } + if (spec === undefined && pi === undefined && isProtocol(routeApi)) { + return routeApi; + } + return "openai-completions"; +}; + +/** + * The pi model one plane serves for one entry. Never throws. + * + * @param input - the entry, its sources and the route's settings. + * @returns the model, or the error pi-ai would record against it. + */ +export const planeModelFor = (input: PlaneModelInput): PlaneModelResult => { + const { entry, override, pi, route, spec } = input; + if (typeof entry.id !== "string") { + return { + error: `llm-pi-ai: provider "${route}" has a model whose id is not a string`, + }; + } + const { id } = entry; + const fail = (detail: string): PlaneModelResult => ({ + error: `llm-pi-ai: provider "${route}" model "${id}" ${detail}`, + }); + const protocol = protocolOf(spec, pi, input.routeApi); + const compat = compatFor( + route, + id, + protocol, + entry.compat, + input.routeCompat, + pi + ); + if (compat.error !== undefined) { + return { error: compat.error }; + } + + const contextWindow = + entry.contextWindow ?? + override?.contextWindow ?? + spec?.context_window ?? + pi?.contextWindow ?? + input.defaults.contextWindow; + if (!isWholePositive(contextWindow)) { + return fail("contextWindow must be a positive integer"); + } + const maxTokens = + entry.maxTokens ?? + override?.maxTokens ?? + spec?.max_output_tokens ?? + pi?.maxTokens ?? + input.defaults.maxTokens; + if (!isWholePositive(maxTokens)) { + return fail("maxTokens must be a positive integer"); + } + const configuredMaxTokens = entry.maxTokens ?? override?.maxTokens; + + // Reasoning: the user's dict, the override's, the catalog's, pi's, none. + const declaredEfforts = + entry.reasoningEfforts === undefined + ? override?.reasoningEfforts + : entry.reasoningEfforts; + let reasoning: boolean; + let thinkingLevelMap: Readonly> | undefined; + let effortNames: Readonly> | undefined; + if (declaredEfforts === undefined) { + const fromCatalog = + spec === undefined + ? undefined + : reasoningFor(spec, protocol, compat.compat); + if (fromCatalog !== undefined) { + reasoning = true; + ({ thinkingLevelMap, effortNames } = fromCatalog); + } else if (spec === undefined && pi?.reasoning === true) { + reasoning = true; + ({ thinkingLevelMap } = pi); + } else { + reasoning = false; + } + } else { + const declared = piReasoningFromEfforts( + id, + route, + effortsDictOf(declaredEfforts) + ); + if ("error" in declared) { + return { error: declared.error }; + } + ({ reasoning, thinkingLevelMap } = declared); + } + + const { + compat: _piCompat, + thinkingLevelMap: _piMap, + ...piRest + } = pi ?? { id }; + const name = + [entry.name, override?.name, spec?.name, pi?.name].find( + (candidate): candidate is string => typeof candidate === "string" + ) ?? id; + const model = { + ...piRest, + api: protocol, + baseUrl: baseUrlFor( + route, + protocol, + spec?.provider_npm, + input.endpoint, + input.familyApi, + input.routeBaseURL, + pi + ), + contextWindow, + cost: + spec?.cost === undefined + ? (pi?.cost ?? NO_COST) + : { + cacheRead: spec.cost.cache_read ?? 0, + cacheWrite: spec.cost.cache_write ?? 0, + input: spec.cost.input ?? 0, + output: spec.cost.output ?? 0, + }, + id, + input: declaredInput(entry.input) ?? + declaredInput(override?.input) ?? + (spec === undefined ? undefined : catalogInput(spec)) ?? + declaredInput(pi?.input) ?? [...input.defaults.input], + maxTokens, + name, + provider: route, + reasoning, + ...(thinkingLevelMap === undefined ? {} : { thinkingLevelMap }), + ...(compat.compat === undefined ? {} : { compat: compat.compat }), + }; + return { + model, + ...(isWholePositive(configuredMaxTokens) ? { configuredMaxTokens } : {}), + ...(effortNames === undefined ? {} : { effortNames }), + }; +}; diff --git a/src/utils/plane-profile.ts b/src/utils/plane-profile.ts new file mode 100644 index 00000000..4071f47d --- /dev/null +++ b/src/utils/plane-profile.ts @@ -0,0 +1,608 @@ +/** + * A plane's resolved profile, built from its raw entry the way pi-ai builds + * one for any route (`llm-pi-ai/src/config.ts` `resolveProfiles`), minus the + * one refusal the plugin exists to lift: an id pi's catalog does not describe + * no longer "needs an api" (D15), because {@link planeModelFor} classifies it. + * + * Every field comes from the entry or from pi-ai's own defaults, none from a + * sibling route. Every check pi-ai makes is mirrored with its message, and + * every one is DEFERRED: a malformed model lands in `modelErrors` (the adapter + * answers `INVALID_CONFIG` for it and serves the rest), and a malformed route + * lands in `catalogError` with no `piProvider` (every model on it answers + * `INVALID_CONFIG`). Nothing here throws, whatever the input: a throw would + * reach the plugin's reconcile, and through it the route. + * + * The host pieces it needs — pi-ai's `Config`, the loader's `interpolate`, + * dsh-llm's `resolveRetryPolicy`, pi's provider builder — come in as + * functions, so the module stays pure. + * + * @module dsh-opencode-patch/utils/plane-profile + */ + +import { isRecord } from "./guards.ts"; +import { usesCatalogThinkingLevels } from "./model-metadata.ts"; +import { + type EndpointChoice, + type PlaneCatalogSpec, + type PlaneModelEntry, + type PlanePiModel, + planeModelFor, +} from "./plane-model.ts"; +import { + allOfferedCompatFields, + compatProtocols, + isGatedCompatField, + isServableSdk, + PLANE_ROUTES, + type PlaneRoute, +} from "./protocols.ts"; + +/** The largest timer delay a stream watchdog can arm (`MAX_TIMER_DELAY_MS`). */ +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +/** pi-ai's credential-ref grammar (`dsh-credentials` `REF_PATTERN`). */ +const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u; + +/** pi-ai's defaults for a profile that states none (`config.ts`). */ +const DEFAULTS = { + contextWindow: 262_144, + input: ["text"], + maxRequestImageBytes: 20 * 1024 * 1024, + maxTokens: 32_768, + requestImageMaxBytes: 1024 * 1024, + requestImagePixelBudget: 2048 * 2048, + streamIdleTimeoutMs: 300_000, +} as const; + +/** A plane-copy of a raw config: never the input, never mutated. */ +export const withoutPlaneProviders = (raw: unknown): unknown => { + if (!isRecord(raw) || !isRecord(raw.providers)) { + return isRecord(raw) ? { ...raw } : raw; + } + const { opencode: _zen, "opencode-go": _go, ...rest } = raw.providers; + return { ...raw, providers: { ...rest } }; +}; + +/** Whether a raw config declares either plane. */ +export const hasPlaneEntries = (raw: unknown): boolean => { + const providers: unknown = isRecord(raw) ? raw.providers : undefined; + return ( + isRecord(providers) && + PLANE_ROUTES.some((plane) => Object.hasOwn(providers, plane)) + ); +}; + +/** The declared planes of a raw config, by route. */ +export const planeEntriesOf = ( + raw: unknown +): Partial> => { + const out: Partial> = {}; + if (!isRecord(raw) || !isRecord(raw.providers)) { + return out; + } + for (const plane of PLANE_ROUTES) { + if (Object.hasOwn(raw.providers, plane)) { + out[plane] = raw.providers[plane]; + } + } + return out; +}; + +/** The provider builder: pi's `createProvider` over the four protocols. */ +export type PlaneProviderBuilder = (input: { + readonly id: PlaneRoute; + readonly name: string; + readonly models: readonly Readonly>[]; + /** + * The served ids the catalog describes by Google thinking level + * ({@link usesCatalogThinkingLevels}, D16); none when absent. + */ + readonly levelThinkingModels?: ReadonlySet; +}) => unknown; + +/** What {@link planeProfileFor} takes. */ +export interface PlaneProfileInput { + readonly route: PlaneRoute; + /** The raw entry, as the user's file holds it. */ + readonly entry: unknown; + /** The loader's `interpolate`, bound to a context; absent without a loader. */ + readonly interpolate?: (value: unknown) => unknown; + /** pi-ai's `Config` for one entry: `(entry) => Config({providers:{[route]: entry}})…[route]`. */ + readonly normalize?: (entry: unknown) => unknown; + /** The plugin's catalog for the plane, servable or not. */ + readonly catalog: readonly PlaneCatalogSpec[]; + /** pi's installed models for the plane. */ + readonly piModels: readonly PlanePiModel[]; + readonly endpoint: EndpointChoice; + readonly familyApi: Readonly>; + /** dsh-llm's `resolveRetryPolicy`. */ + readonly resolveRetryPolicy: (policy: unknown, label: string) => unknown; + readonly buildProvider: PlaneProviderBuilder; +} + +/** What {@link planeProfileFor} answers. */ +export interface PlaneProfileResult { + /** The `ResolvedPiAiProviderProfile` the adapter reads. */ + readonly profile: Readonly> & { + readonly provider: PlaneRoute; + readonly displayName: string; + readonly modelErrors: ReadonlyMap; + readonly configuredMaxTokens: ReadonlyMap; + readonly retryPolicy: unknown; + readonly piProvider?: unknown; + readonly catalogError?: string; + }; + /** Per model, the effort display names the catalog decided (D1's "On"). */ + readonly effortNames: ReadonlyMap>>; + /** The interpolated, normalized entry, for the Settings graft; absent on a route failure before it. */ + readonly normalized?: Readonly>; +} + +const errorText = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** Whether a value holds an un-interpolated `{__jsExpr}` node anywhere. */ +const holdsJsExpr = (value: unknown): boolean => { + if (Array.isArray(value)) { + return value.some(holdsJsExpr); + } + if (!isRecord(value)) { + return false; + } + return "__jsExpr" in value || Object.values(value).some(holdsJsExpr); +}; + +/** pi-ai's route-level checks before any model resolves (`config.ts:370-465`). */ +const checkRoute = ( + route: PlaneRoute, + raw: Readonly>, + source: Readonly> +): void => { + const fail = (detail: string): never => { + throw new Error(`llm-pi-ai: provider "${route}" ${detail}`); + }; + if ("provider" in raw || "provider" in source) { + fail('sets "provider", which moved to the providers dict key'); + } + for (const side of [raw, source]) { + if ("maxRetries" in side || "maxRetryDelayMs" in side) { + fail( + "sets maxRetries or maxRetryDelayMs, which were removed; compose agent recovery with dsh-llm-retry" + ); + } + } + if (source.baseURL !== undefined && source.baseURL === "") { + fail("has an empty baseURL"); + } + if (source.displayName !== undefined && source.displayName === "") { + fail("has an empty displayName"); + } + const { headers } = source; + if (headers !== undefined) { + if (!isRecord(headers)) { + return fail("headers must be a dict of strings"); + } + for (const [name, value] of Object.entries(headers)) { + try { + Reflect.construct(Headers, [[[name, String(value)]]]); + } catch { + fail( + `header "${name}" is not valid for Fetch; use a valid HTTP field name and a single-line value representable as bytes` + ); + } + } + } + const idle = source.streamIdleTimeoutMs ?? DEFAULTS.streamIdleTimeoutMs; + if ( + typeof idle !== "number" || + !Number.isFinite(idle) || + idle <= 0 || + idle > MAX_TIMER_DELAY_MS + ) { + fail( + `streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}` + ); + } + const image = source.maxRequestImageBytes ?? DEFAULTS.maxRequestImageBytes; + if (typeof image !== "number" || !Number.isInteger(image) || image <= 0) { + fail("maxRequestImageBytes must be a positive integer"); + } + for (const [key, fallback] of [ + ["requestImagePixelBudget", DEFAULTS.requestImagePixelBudget], + ["requestImageMaxBytes", DEFAULTS.requestImageMaxBytes], + ] as const) { + const value = source[key] ?? fallback; + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value <= 0 + ) { + fail(`${key} must be a positive safe integer`); + } + } + if (Array.isArray(source.defaultInput) && source.defaultInput.length === 0) { + fail("defaultInput must name at least one modality"); + } + if ( + source.apiKeyEnv !== undefined && + (typeof source.apiKeyEnv !== "string" || + !REF_PATTERN.test(source.apiKeyEnv)) + ) { + fail( + `apiKeyEnv: credential ref ${JSON.stringify(source.apiKeyEnv)} must match ${String(REF_PATTERN)}` + ); + } + if (source.models !== undefined && !Array.isArray(source.models)) { + fail("models must be a list of model entries"); + } +}; + +/** pi-ai's compat vocabulary check (`catalog.ts:566-599`). */ +const compatVocabularyError = ( + route: PlaneRoute, + site: string, + compat: unknown +): string | undefined => { + if (compat === undefined) { + return undefined; + } + if (!isRecord(compat)) { + return `llm-pi-ai: provider "${route}" ${site} compat must be a dict`; + } + for (const [field, value] of Object.entries(compat)) { + if (compatProtocols(field).length === 0) { + return isGatedCompatField(field) + ? `llm-pi-ai: provider "${route}" ${site} sets compat "${field}", which is not configurable here: pi-ai's installed catalog sets it for the vendors that need it, so name that provider as the route instead` + : `llm-pi-ai: provider "${route}" ${site} sets compat "${field}", which no wire protocol declares; the configurable switches are ${allOfferedCompatFields().join(", ")}`; + } + if (value === null || value === undefined) { + return `llm-pi-ai: provider "${route}" ${site} sets compat "${field}" with no value; give it one, or remove the key to leave the field to the next layer — the installed catalog entry, then pi-ai's own detection`; + } + } + return undefined; +}; + +/** A compat dict's fields that carry a value (an empty dict states nothing). */ +const setCompatFields = (compat: unknown): string[] => + isRecord(compat) + ? Object.entries(compat) + .filter( + ([, value]) => !(isRecord(value) && Object.keys(value).length === 0) + ) + .map(([field]) => field) + : []; + +/** + * The served Google ids whose catalog spec describes thinking by level (D16): + * the protocol and the levels both come from the catalog, never the id. + */ +const levelThinkingModelsOf = ( + served: readonly Readonly>[], + catalog: readonly PlaneCatalogSpec[] +): ReadonlySet => { + const specs = new Map(catalog.map((spec) => [spec.id, spec])); + const ids = new Set(); + for (const model of served) { + const spec = specs.get(String(model.id)); + if ( + model.api === "google-generative-ai" && + spec !== undefined && + usesCatalogThinkingLevels(spec) + ) { + ids.add(spec.id); + } + } + return ids; +}; + +const numberOr = (value: unknown, fallback: number): number => + typeof value === "number" ? value : fallback; + +/** + * The resolved profile of one plane. Never throws. + * + * @param input - the raw entry and the host pieces. + * @returns the profile, the effort names, and the normalized entry. + */ +export const planeProfileFor = ( + input: PlaneProfileInput +): PlaneProfileResult => { + const { route } = input; + const modelErrors = new Map(); + const configuredMaxTokens = new Map(); + const effortNames = new Map>>(); + let routeError: string | undefined; + let source: Record = {}; + let normalized: Record | undefined; + try { + let raw: unknown = input.entry; + if (input.interpolate !== undefined) { + raw = input.interpolate(raw); + } + if (!isRecord(raw)) { + throw new Error( + `llm-pi-ai: provider "${route}" profile must be a dict of settings` + ); + } + // A `{__jsExpr}` node nothing interpolated is an error of the model that + // holds it, so the rest of the route still serves; one at route level + // fails the route. + const kept: Record = { ...raw }; + if (Array.isArray(raw.models)) { + kept.models = raw.models.filter((model: unknown) => { + if (!holdsJsExpr(model)) { + return true; + } + const id = isRecord(model) ? model.id : undefined; + modelErrors.set( + typeof id === "string" ? id : "", + `llm-pi-ai: provider "${route}" model "${String(id)}" holds a !!js expression no loader interpolated` + ); + return false; + }); + } + if (holdsJsExpr({ ...kept, models: undefined })) { + throw new Error( + `llm-pi-ai: provider "${route}" holds a !!js expression no loader interpolated` + ); + } + const normal = input.normalize === undefined ? kept : input.normalize(kept); + if (!isRecord(normal)) { + throw new Error( + `llm-pi-ai: provider "${route}" profile must be a dict of settings` + ); + } + source = normal; + normalized = normal; + checkRoute(route, kept, source); + } catch (error) { + routeError = errorText(error); + } + + let retryPolicy: unknown; + try { + retryPolicy = input.resolveRetryPolicy( + source.retryPolicy, + `llm-pi-ai: provider "${route}" retryPolicy` + ); + } catch (error) { + routeError ??= errorText(error); + retryPolicy = input.resolveRetryPolicy( + undefined, + `llm-pi-ai: provider "${route}" retryPolicy` + ); + } + + const displayName = + typeof source.displayName === "string" && source.displayName.length > 0 + ? source.displayName + : route; + const models: Readonly>[] = []; + if (routeError === undefined) { + try { + models.push( + ...resolveModels( + input, + source, + modelErrors, + configuredMaxTokens, + effortNames + ) + ); + } catch (error) { + routeError = errorText(error); + } + } + + let piProvider: unknown; + if (routeError === undefined) { + try { + const served = models.filter( + (model) => !modelErrors.has(String(model.id)) + ); + piProvider = input.buildProvider({ + id: route, + levelThinkingModels: levelThinkingModelsOf(served, input.catalog), + models: served, + name: displayName, + }); + } catch (error) { + routeError = errorText(error); + } + } + const catalogError: string | undefined = + modelErrors.values().next().value ?? routeError; + + const { + apiKeyEnv, + retryPolicy: _retry, + models: _models, + displayName: _display, + ...rest + } = source; + return { + effortNames, + ...(normalized === undefined ? {} : { normalized }), + profile: { + ...rest, + provider: route, + displayName, + ...(typeof apiKeyEnv === "string" && REF_PATTERN.test(apiKeyEnv) + ? { apiKeyEnv } + : {}), + streamIdleTimeoutMs: numberOr( + source.streamIdleTimeoutMs, + DEFAULTS.streamIdleTimeoutMs + ), + maxRequestImageBytes: numberOr( + source.maxRequestImageBytes, + DEFAULTS.maxRequestImageBytes + ), + requestImagePixelBudget: numberOr( + source.requestImagePixelBudget, + DEFAULTS.requestImagePixelBudget + ), + requestImageMaxBytes: numberOr( + source.requestImageMaxBytes, + DEFAULTS.requestImageMaxBytes + ), + retryPolicy, + ...(isRecord(rest.headers) ? { headers: { ...rest.headers } } : {}), + ...(isRecord(rest.thinkingBudgets) + ? { thinkingBudgets: { ...rest.thinkingBudgets } } + : {}), + configuredMaxTokens, + modelErrors, + ...(piProvider === undefined ? {} : { piProvider }), + ...(catalogError === undefined ? {} : { catalogError }), + }, + }; +}; + +/** + * Every model the route serves, as pi-ai's `resolveRouteModels` materializes + * them (`catalog.ts:780-950`), with {@link planeModelFor} in place of pi-ai's + * per-model resolution. Model failures go to `modelErrors`; a route failure + * throws, and the caller records the message as the route's failure. + */ +const resolveModels = ( + input: PlaneProfileInput, + source: Readonly>, + modelErrors: Map, + configuredMaxTokens: Map, + effortNames: Map>> +): Readonly>[] => { + const { route } = input; + const fail = (detail: string): never => { + throw new Error(`llm-pi-ai: provider "${route}" ${detail}`); + }; + const specs = new Map(input.catalog.map((spec) => [spec.id, spec])); + const pis = new Map(input.piModels.map((model) => [model.id, model])); + const configured = Array.isArray(source.models) ? source.models : []; + const overrides = isRecord(source.modelOverrides) + ? source.modelOverrides + : {}; + for (const [id, override] of Object.entries(overrides)) { + if (id.length === 0) { + fail("has a modelOverrides entry with an empty model id"); + } + if (configured.length > 0) { + fail( + `sets modelOverrides for "${id}" beside a models list; models already replaces the served catalog, so declare the fields on its entries` + ); + } + if (!specs.has(id) && !pis.has(id)) { + modelErrors.set( + id, + `llm-pi-ai: provider "${route}" modelOverrides names "${id}", which the installed catalog does not describe` + ); + } + if (isRecord(override) && "id" in override) { + fail(`modelOverrides entry "${id}" sets "id", which is the dict key`); + } + } + // D3: no models list serves the plugin's servable catalog for the plane. + const entries: unknown[] = + configured.length > 0 + ? configured + : input.catalog + .filter((spec) => isServableSdk(spec.provider_npm)) + .map((spec) => ({ id: spec.id })); + if (entries.length === 0) { + fail( + "resolves no models; the installed catalog does not describe this route, so its models must be listed in configuration" + ); + } + const routeCompatError = compatVocabularyError(route, "route", source.compat); + if (routeCompatError !== undefined) { + throw new Error(routeCompatError); + } + const routeCompat = isRecord(source.compat) ? source.compat : undefined; + const seen = new Set(); + const models: Readonly>[] = []; + const defaults = { + contextWindow: numberOr( + source.defaultContextWindow, + DEFAULTS.contextWindow + ), + input: Array.isArray(source.defaultInput) + ? source.defaultInput.filter( + (item): item is string => typeof item === "string" + ) + : [...DEFAULTS.input], + maxTokens: numberOr(source.defaultMaxTokens, DEFAULTS.maxTokens), + }; + for (const candidate of entries) { + const entry: PlaneModelEntry = isRecord(candidate) ? candidate : {}; + const id = typeof entry.id === "string" ? entry.id : undefined; + const key = id ?? ""; + const modelFail = (message: string): void => { + modelErrors.set(key, message); + }; + const vocabulary = compatVocabularyError( + route, + `model "${String(entry.id)}"`, + entry.compat + ); + if (vocabulary !== undefined) { + modelFail(vocabulary); + continue; + } + if (id === "") { + modelFail(`llm-pi-ai: provider "${route}" has a model with an empty id`); + continue; + } + if (id !== undefined && seen.has(id)) { + modelFail( + `llm-pi-ai: provider "${route}" lists model "${id}" more than once` + ); + continue; + } + if (id !== undefined) { + seen.add(id); + } + const override = + configured.length === 0 && id !== undefined && isRecord(overrides[id]) + ? (overrides[id] as PlaneModelEntry) + : undefined; + const resolved = planeModelFor({ + defaults, + endpoint: input.endpoint, + entry, + familyApi: input.familyApi, + route, + ...(override === undefined ? {} : { override }), + ...(id === undefined || !pis.has(id) ? {} : { pi: pis.get(id) }), + ...(id === undefined || !specs.has(id) ? {} : { spec: specs.get(id) }), + ...(typeof source.api === "string" ? { routeApi: source.api } : {}), + ...(typeof source.baseURL === "string" + ? { routeBaseURL: source.baseURL } + : {}), + ...(routeCompat === undefined ? {} : { routeCompat }), + }); + if (resolved.error !== undefined) { + modelFail(resolved.error); + continue; + } + const served = resolved.model.id; + if (resolved.configuredMaxTokens !== undefined) { + configuredMaxTokens.set(served, resolved.configuredMaxTokens); + } + if (resolved.effortNames !== undefined) { + effortNames.set(served, resolved.effortNames); + } + models.push(resolved.model); + } + const serviceable = models.filter( + (model) => !modelErrors.has(String(model.id)) + ); + for (const field of setCompatFields(routeCompat)) { + const takers = compatProtocols(field); + if (!serviceable.some((model) => takers.includes(String(model.api)))) { + fail( + `sets compat "${field}", but no model on the route speaks a protocol that takes it; it exists on ${takers.join(", ")}` + ); + } + } + return models; +}; diff --git a/src/utils/protocols.ts b/src/utils/protocols.ts new file mode 100644 index 00000000..55a0e056 --- /dev/null +++ b/src/utils/protocols.ts @@ -0,0 +1,285 @@ +/** + * The wire protocols the plane routes speak, and the tables that place each + * one on the gateway. + * + * The catalog names an SDK per model only where the model differs from the + * provider's default, and that SDK is the vendor's own statement of the + * protocol (D16): {@link PROTOCOL_FOR_SDK} maps it, and nothing here guesses + * from a model's name. `@ai-sdk/mistral` maps to completions because that is + * where pi 1.1.0's own data files `mistral-large-4`. + * + * Every value is a constant or a function of its arguments: this module is in + * `utils/` and the architecture test holds it to that. + * + * @module dsh-opencode-patch/utils/protocols + */ + +/** The SDK whose models are served on the Responses API. */ +export const RESPONSES_SDK = "@ai-sdk/openai"; + +/** The SDK whose models are served on the Messages API. */ +export const ANTHROPIC_SDK = "@ai-sdk/anthropic"; + +/** The SDK whose models speak pi's `google-generative-ai` protocol. */ +export const GOOGLE_SDK = "@ai-sdk/google"; + +/** The SDK the catalog names for Mistral's models, served as completions. */ +export const MISTRAL_SDK = "@ai-sdk/mistral"; + +/** The four protocols the plane adapter dispatches, in pi's own names. */ +export const PROTOCOLS = [ + "openai-completions", + "openai-responses", + "anthropic-messages", + "google-generative-ai", +] as const; + +/** One of {@link PROTOCOLS}. */ +export type Protocol = (typeof PROTOCOLS)[number]; + +/** Whether a value names one of the four protocols. */ +export const isProtocol = (value: unknown): value is Protocol => + typeof value === "string" && (PROTOCOLS as readonly string[]).includes(value); + +/** + * The protocol each SDK a model may name speaks. A model naming no SDK speaks + * the provider default, completions ({@link protocolForSdk}). + */ +export const PROTOCOL_FOR_SDK: Readonly> = { + [RESPONSES_SDK]: "openai-responses", + [ANTHROPIC_SDK]: "anthropic-messages", + [GOOGLE_SDK]: "google-generative-ai", + [MISTRAL_SDK]: "openai-completions", +}; + +/** + * Whether a model's SDK has a protocol here: none named (the default), or one + * {@link PROTOCOL_FOR_SDK} maps. + * + * @param sdk - the model's `provider_npm`, if any. + */ +export const isServableSdk = (sdk?: unknown): boolean => + sdk === undefined || + (typeof sdk === "string" && Object.hasOwn(PROTOCOL_FOR_SDK, sdk)); + +/** + * The protocol a catalog SDK speaks. + * + * @param sdk - the model's `provider_npm`, if any. + * @returns completions for none, the mapped protocol, or `undefined` for an + * SDK nothing here serves. + */ +export const protocolForSdk = (sdk?: unknown): Protocol | undefined => { + if (sdk === undefined) { + return "openai-completions"; + } + return typeof sdk === "string" && Object.hasOwn(PROTOCOL_FOR_SDK, sdk) + ? PROTOCOL_FOR_SDK[sdk] + : undefined; +}; + +/** The two routes the plugin owns. */ +export const PLANE_ROUTES = ["opencode", "opencode-go"] as const; + +/** One of {@link PLANE_ROUTES}. */ +export type PlaneRoute = (typeof PLANE_ROUTES)[number]; + +/** Whether a value names one of the plane routes. */ +export const isPlaneRoute = (value: unknown): value is PlaneRoute => + value === "opencode" || value === "opencode-go"; + +/** + * The older endpoint each plane's completions requests went to. Still served, + * measured, and what `endpoint: legacy` selects. + */ +export const LEGACY_PLANE_API: Readonly> = { + opencode: "https://opencode.ai/zen/v1", + "opencode-go": "https://opencode.ai/zen/go/v1", +}; + +/** + * Go's Google protocol. The legacy family has no Google base on Go, so this is + * the one cell every family shares (`OPENCODE_FAMILY_API.go["@ai-sdk/google"]`). + */ +export const GO_GOOGLE_BASE_URL = + "https://opencode.ai/inference/go/google/v1beta"; + +/** + * The legacy family per plane and protocol: one base for every protocol but + * Anthropic, whose SDK appends `/v1/messages` itself, and Go's Google. + */ +export const LEGACY_BASE: Readonly< + Record>> +> = { + opencode: { + "anthropic-messages": "https://opencode.ai/zen", + "google-generative-ai": "https://opencode.ai/zen/v1", + "openai-completions": "https://opencode.ai/zen/v1", + "openai-responses": "https://opencode.ai/zen/v1", + }, + "opencode-go": { + "anthropic-messages": "https://opencode.ai/zen/go", + "google-generative-ai": GO_GOOGLE_BASE_URL, + "openai-completions": "https://opencode.ai/zen/go/v1", + "openai-responses": "https://opencode.ai/zen/go/v1", + }, +}; + +/** + * The modern (per-protocol inference) family, for a catalog whose family map + * names no base for a protocol. Measured from the vendor's own config; the + * Anthropic entry keeps its `/v1`, which the caller strips. + */ +export const MODERN_FALLBACK: Readonly< + Record>> +> = { + opencode: { + "anthropic-messages": "https://opencode.ai/inference/anthropic/v1", + "google-generative-ai": "https://opencode.ai/inference/google/v1beta", + "openai-completions": "https://opencode.ai/inference/openai/v1", + "openai-responses": "https://opencode.ai/inference/openai/v1", + }, + "opencode-go": { + "anthropic-messages": "https://opencode.ai/inference/go/anthropic/v1", + "google-generative-ai": GO_GOOGLE_BASE_URL, + "openai-completions": "https://opencode.ai/inference/go/openai/v1", + "openai-responses": "https://opencode.ai/inference/go/openai/v1", + }, +}; + +/** + * The key each protocol's family is filed under in the catalog's family map + * (`OPENCODE_FAMILY_API`): `""` is the provider default, completions. + */ +export const FAMILY_SDK_KEY: Readonly> = { + "anthropic-messages": ANTHROPIC_SDK, + "google-generative-ai": GOOGLE_SDK, + "openai-completions": "", + "openai-responses": RESPONSES_SDK, +}; + +/** How pi-ai disposes one compat field: settable by a profile, or not. */ +type Disposition = "offer" | "withhold"; + +/** + * pi-ai's compat gate of every protocol (`llm-pi-ai/src/catalog.ts`, the + * `COMPAT_GATES` tables), restated so the plane profile refuses and keeps + * exactly what pi-ai's own resolution would. Order is pi-ai's, because a + * diagnostic lists the fields in it. + */ +export const COMPAT_GATES: Readonly< + Record>> +> = (() => { + const completions: Record = { + supportsStore: "offer", + supportsDeveloperRole: "offer", + supportsReasoningEffort: "offer", + supportsUsageInStreaming: "offer", + supportsFinishReason: "offer", + maxTokensField: "offer", + requiresToolResultName: "offer", + requiresAssistantAfterToolResult: "offer", + requiresThinkingAsText: "offer", + requiresReasoningContentOnAssistantMessages: "offer", + thinkingFormat: "offer", + chatTemplateKwargs: "offer", + chatTemplateArgs: "offer", + supportsThinkingTokenBudget: "offer", + thinkingTokenBudgetField: "offer", + vllmPriority: "offer", + supportsStrictMode: "offer", + cacheControlFormat: "offer", + supportsLongCacheRetention: "offer", + openRouterRouting: "withhold", + vercelGatewayRouting: "withhold", + zaiToolStream: "withhold", + supportsOpenAIGrammarTools: "withhold", + sendSessionAffinityHeaders: "withhold", + supportsMidConvoSystemMessages: "withhold", + supportsMidConvoToolAdditions: "withhold", + sessionAffinityFormat: "withhold", + }; + const responses: Record = { + supportsDeveloperRole: "offer", + supportsMaxOutputTokens: "offer", + supportsStrictMode: "offer", + supportsLongCacheRetention: "offer", + sessionAffinityFormat: "withhold", + supportsOpenAIGrammarTools: "withhold", + supportsAdditionalTools: "withhold", + supportsToolSearch: "withhold", + supportsExplicitPromptCacheMode: "withhold", + supportsMidConvoSystemMessages: "withhold", + }; + return { + "mistral-conversations": { supportsMidConvoSystemMessages: "withhold" }, + "openai-completions": completions, + "openai-responses": responses, + "azure-openai-responses": responses, + "openai-codex-responses": responses, + "anthropic-messages": { + supportsEagerToolInputStreaming: "offer", + supportsLongCacheRetention: "offer", + supportsCacheControlOnTools: "offer", + supportsTemperature: "offer", + forceAdaptiveThinking: "offer", + allowEmptySignature: "offer", + supportsStrictTools: "offer", + sendSessionAffinityHeaders: "withhold", + sessionAffinityFormat: "withhold", + supportsMidConvoSystemMessages: "withhold", + supportsMidConvoToolChanges: "withhold", + supportsMidConvoEffort: "withhold", + allowedFallbackModels: "withhold", + }, + "bedrock-converse-stream": { supportsStrictMode: "offer" }, + }; +})(); + +/** The fields one gate offers, in its order. */ +const offeredIn = ( + gate: Readonly> = {} +): ReadonlySet => + new Set( + Object.entries(gate).flatMap(([field, disposition]) => + disposition === "offer" ? [field] : [] + ) + ); + +/** The compat fields a profile may set on each plane protocol. */ +export const OFFERED_COMPAT: Readonly>> = { + "anthropic-messages": offeredIn(COMPAT_GATES["anthropic-messages"]), + "google-generative-ai": offeredIn(COMPAT_GATES["google-generative-ai"]), + "openai-completions": offeredIn(COMPAT_GATES["openai-completions"]), + "openai-responses": offeredIn(COMPAT_GATES["openai-responses"]), +}; + +/** + * The protocols offering one compat field, in gate order: empty for a withheld + * field or one no protocol declares. + * + * @param field - the compat field. + */ +export const compatProtocols = (field: string): string[] => + Object.entries(COMPAT_GATES).flatMap(([api, gate]) => + gate[field] === "offer" ? [api] : [] + ); + +/** Every offered compat field across the gates, deduplicated, in gate order. */ +export const allOfferedCompatFields = (): string[] => { + const fields = new Set(); + for (const gate of Object.values(COMPAT_GATES)) { + for (const field of offeredIn(gate)) { + fields.add(field); + } + } + return [...fields]; +}; + +/** + * Whether some gate names a field at all (offered or withheld). + * + * @param field - the compat field. + */ +export const isGatedCompatField = (field: string): boolean => + Object.values(COMPAT_GATES).some((gate) => gate[field] !== undefined); diff --git a/src/utils/request-shape.ts b/src/utils/request-shape.ts index ee6d4854..2e5e8194 100644 --- a/src/utils/request-shape.ts +++ b/src/utils/request-shape.ts @@ -258,10 +258,10 @@ export const ANTHROPIC_VERSION = "2023-06-01"; /** * Whether a provider route is OpenCode Zen rather than Go. * - * The Go plane is `opencode-go` and its own `opencode-go-*` internal routes — - * the rule `catalogPlaneForRoute` encodes. Everything else in the opencode - * family is Zen pay-as-you-go, including `opencode-google`, the Zen Google - * route, which starts with `opencode-go` but not with `opencode-go-`. + * The Go plane is `opencode-go` and any `opencode-go-*` relay route — the rule + * `catalogPlaneForRoute` encodes. Everything else in the opencode family is + * Zen pay-as-you-go, including a route named `opencode-google`, which starts + * with `opencode-go` but not with `opencode-go-`. */ export const isZenProvider = (provider?: string): boolean => { if (typeof provider !== "string") { diff --git a/test/architecture.test.ts b/test/architecture.test.ts index 467290f4..84215977 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -165,7 +165,9 @@ describe("architecture · a property read is a declared dependency", () => { "on", "plugin", "provide", + "registry", "root", + "waterfall", ]); /** @@ -174,7 +176,7 @@ describe("architecture · a property read is a declared dependency", () => { * Every read there is optional (`ctx.remote === undefined`, * `ctx.slots?.inject?.(…)`), which is the pattern the host half must use for * its optional services and does. They are not `inject` declarations because - * the host plugin declares only `llm` — see the next test. + * the host plugin declares nothing at its root — see the next test. */ const CLIENT_OPTIONAL = new Set([ "configForms", @@ -184,8 +186,12 @@ describe("architecture · a property read is a declared dependency", () => { "slots", ]); - /** The one service the plugin DECLARES, so reading it as a property is right. */ - const DECLARED = new Set(["llm"]); + /** + * The services the plugin DECLARES at its root, so reading one as a property + * is right: none. `llm` and `settings` are reached through `ctx.inject` + * child scopes, as `scope.llm`. + */ + const DECLARED = new Set(); /** Every `ctx.` in a file, excluding `ctx.get("name")`. */ const serviceReads = (file: string): string[] => { @@ -213,7 +219,7 @@ describe("architecture · a property read is a declared dependency", () => { const sourceFiles = (): string[] => filesUnder(SRC); - it("reads no host service as a property except the declared `llm`", () => { + it("reads no host service as a property: the root declares none", () => { // Cordis THROWS `cannot get property "X" without inject` for an undeclared // name, and its `inject` form has no required/optional split: a declared // service is REQUIRED, so declaring the optional ones would stop the plugin @@ -233,9 +239,9 @@ describe("architecture · a property read is a declared dependency", () => { expect(offenders).toEqual([]); }); - it("declares `llm`, the one service the plugin cannot work without", async () => { + it("declares no service at the root, so its config listener attaches before `llm` exists", async () => { const { inject } = await import("./support/src.ts"); - expect(inject).toContain("llm"); + expect(inject).toEqual([]); }); }); @@ -499,7 +505,8 @@ describe("architecture · the host bundle mutates no registry it does not own", it("walks a graph that actually contains the host modules", () => { const graph = runtimeGraph(ENTRY); expect(graph.has("stream-hook.ts")).toBe(true); - expect(graph.has("responses-provider.ts")).toBe(true); + expect(graph.has("host/route-claim.ts")).toBe(true); + expect(graph.has("host/plane-provider.ts")).toBe(true); expect(graph.has("settings-page.tsx")).toBe(false); expect(REGISTRY_WRITE.test('llm.adapters.delete("opencode")')).toBe(true); expect(REGISTRY_WRITE.test('llm.adapters?.delete("opencode")')).toBe(true); @@ -862,3 +869,55 @@ describe("architecture · every test reaches src through one door", () => { expect(offenders).toEqual([]); }); }); + +describe("architecture · the coverage floors name files that exist", () => { + it("every per-file threshold key in vite.config.ts is a source file", () => { + // A floor on a file that moved or was deleted is a floor on nothing: the + // threshold silently stops measuring. Read from the config's text, which is + // what the coverage run reads. + const config = readFileSync( + join(import.meta.dirname, "..", "vite.config.ts"), + "utf8" + ); + const keys = [...config.matchAll(/^\s*"(src\/[^"]+)":\s*\{/gmu)].map( + (match) => match[1] ?? "" + ); + expect(keys.length, "the config must name per-file floors").toBeGreaterThan( + 0 + ); + const ROOT = join(import.meta.dirname, ".."); + expect(keys.filter((key) => !existsSync(join(ROOT, key)))).toEqual([]); + // The pattern reads a planted key, so an empty answer above means something. + expect( + [...plantedFloor().matchAll(/^\s*"(src\/[^"]+)":\s*\{/gmu)].map( + (match) => match[1] + ) + ).toEqual(["src/x.ts"]); + }); +}); + +/** A config fragment with one per-file floor, for the pattern's known answer. */ +const plantedFloor = (): string => + ["thresholds: {", ' "src/x.ts": {', " lines: 100,", " },", "}"].join( + "\n" + ); + +describe("architecture · the modules the plane routes replaced are gone", () => { + it("no hidden-route mount, protocol table, Gemini bridge, pi model builder or replay translator is left in src", () => { + // P-R1 (D13): the plugin serves each plane through its own adapter, so + // the modules that mounted pi-ai a second time under hidden routes, and + // the translators that kept a Gemini replay across two provider labels, + // have nothing left to do. Their survivors live in `utils/protocols.ts`. + const ROOT = join(import.meta.dirname, ".."); + const replaced = [ + "src/responses-provider.ts", + "src/responses-routes.ts", + "src/google-bridge.ts", + "src/pi-provider.ts", + "src/utils/google-replay.ts", + ]; + expect(replaced.filter((file) => existsSync(join(ROOT, file)))).toEqual([]); + // The check reads the tree: the module that took their place is there. + expect(existsSync(join(ROOT, "src/utils/protocols.ts"))).toBe(true); + }); +}); diff --git a/test/attachment-services.test.ts b/test/attachment-services.test.ts index 21c6eaca..8bf01ea6 100644 --- a/test/attachment-services.test.ts +++ b/test/attachment-services.test.ts @@ -1,15 +1,60 @@ import { createRequire } from "node:module"; -import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; import { expect, it, vi } from "vitest"; -import { testPi } from "./google-runtime-fixture.ts"; +import { hostModulesForTests } from "./support/host.ts"; import { - OPENCODE_GO_CATALOG, attachmentResolvers, - registerGoogleBridge, + buildPlaneProvider, + createPlaneAdapter, + OPENCODE_FAMILY_API, + OPENCODE_GO_CATALOG, + planeAuth, + planeProfileFor, } from "./support/src.ts"; +/** + * The plane adapter over the host's own `PiAiAdapter`, serving one Go Gemini + * model, with the image hooks of `images` — the way the route claim builds it. + */ +const goGeminiAdapter = async ( + images: ReturnType, + apiKey: string +) => { + const source = hostModulesForTests(); + const [piAi, libraries] = await Promise.all([ + source.piAi({}), + source.libraries(), + ]); + if (piAi === undefined || libraries === undefined) { + throw new Error("no host modules"); + } + const { profile } = planeProfileFor({ + buildProvider: buildPlaneProvider(libraries), + catalog: OPENCODE_GO_CATALOG, + endpoint: "auto", + entry: { models: [{ id: "gemini-4-argon" }] }, + familyApi: OPENCODE_FAMILY_API.go, + piModels: libraries.builtinModels("opencode-go"), + resolveRetryPolicy: libraries.resolveRetryPolicy, + route: "opencode-go", + }); + const profiles = new Map([["opencode-go", profile]]); + // No effort renames: this model's reasoning is not what the case is about. + const noNames = new Map>>(); + return createPlaneAdapter({ + auth: planeAuth({}, piAi.recordKeyFor, libraries), + effortNames: (_provider, model) => noNames.get(model), + modules: { libraries, piAi }, + profiles: () => profiles, + resolveApiKey: () => Promise.resolve(apiKey), + resolveAttachments: images.resolveAttachments, + resolveImageAccess: images.resolveImageAccess, + }) as unknown as { + stream: (options: unknown) => AsyncIterable; + }; +}; + const scopedServices = (services: Record) => { const cleanups: (() => void)[] = []; return { @@ -50,24 +95,13 @@ it("degrades gracefully when image services or host-path conversion are absent", } }); -it("releases optional service scopes when adapter registration fails", () => { +it("releases both optional service scopes when the resolvers are disposed", () => { + // The route claim builds one pair per activation and disposes it in its + // teardown, whether or not an adapter was ever registered with it. const dispose = vi.fn(); - const ctx = { inject: () => ({ dispose }) }; - expect(() => - registerGoogleBridge({ - ctx, - pi: testPi(), - host: { PiAiAdapter }, - plane: "opencode-go", - apiKeyRef: "UNUSED_KEY", - specs: OPENCODE_GO_CATALOG.filter((spec) => spec.id === "gemini-4-argon"), - llm: { - registerAdapter: () => { - throw new Error("registration failed"); - }, - }, - }) - ).toThrow("registration failed"); + const images = attachmentResolvers({ inject: () => ({ dispose }) }); + expect(dispose).not.toHaveBeenCalled(); + images.dispose(); expect(dispose).toHaveBeenCalledTimes(2); }); @@ -108,7 +142,7 @@ it("uses actual Cordis injection and clears the service on disposal", async () = } }); -it("streams a durable image through the actual Go Gemini adapter", async () => { +it("streams a durable image through the plane adapter's Go Gemini model", async () => { const bytes = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6aSAAAAAASUVORK5CYII=", "base64" @@ -136,33 +170,17 @@ it("streams a durable image through the actual Go Gemini adapter", async () => { const ctx = scopedServices({ attachments: { readImageRequest, imageHostPath: () => null }, }); - let adapter: - | { stream: (options: unknown) => AsyncIterable } - | undefined; - const dispose = registerGoogleBridge({ - pi: testPi(), - ctx, - host: { PiAiAdapter }, - plane: "opencode-go", - apiKeyRef: "BRIDGE_IMAGE_KEY", - specs: OPENCODE_GO_CATALOG.filter((spec) => spec.id === "gemini-4-argon"), - llm: { - registerAdapter: (_routes, value) => { - adapter = value as typeof adapter; - return () => {}; - }, - }, - }); + const images = attachmentResolvers(ctx); + const adapter = await goGeminiAdapter(images, "fixture-image-key"); const original = globalThis.fetch; const bodies: Record[] = []; - process.env.BRIDGE_IMAGE_KEY = "fixture-image-key"; globalThis.fetch = vi.fn(async (_input, init) => { bodies.push(JSON.parse(String(init?.body)) as Record); throw new Error("captured image request"); }); try { - for await (const _chunk of adapter!.stream({ - provider: "opencode-go-google", + for await (const _chunk of adapter.stream({ + provider: "opencode-go", model: "gemini-4-argon", messages: [ { @@ -180,8 +198,7 @@ it("streams a durable image through the actual Go Gemini adapter", async () => { expect(String(error)).toContain("captured image request"); } finally { globalThis.fetch = original; - delete process.env.BRIDGE_IMAGE_KEY; - if (typeof dispose === "function") dispose(); + images.dispose(); ctx.dispose(); } expect(readImageRequest).toHaveBeenCalledOnce(); diff --git a/test/catalog-change.test.ts b/test/catalog-change.test.ts new file mode 100644 index 00000000..807e61d7 --- /dev/null +++ b/test/catalog-change.test.ts @@ -0,0 +1,86 @@ +/** + * The catalog's change signal (`onCatalogChange`, `catalogGeneration`): what + * the route claim's plane profiles hang off, so a refresh that lands after + * boot reaches them. Each case performs the refresh it describes. + * + * @module test/catalog-change.test + */ +import { describe, expect, it, vi } from "vitest"; + +import { + catalogGeneration, + catalogListenerCount, + onCatalogChange, + refreshCatalog, + resetCatalogState, +} from "./support/src.ts"; + +/** A fetch answering one catalog payload, or a failure status. */ +const answering = (body: unknown, status = 200): typeof fetch => + (async () => + status === 200 + ? Response.json(body) + : new Response("no", { status })) as unknown as typeof fetch; + +const landed = { + opencode: { + models: { + "change-probe-model": { + name: "Probe", + provider: { npm: "@ai-sdk/openai" }, + }, + }, + }, +}; + +describe("models-catalog · the change signal", () => { + it("a listener that throws leaves the refresh a success, and the next listener is still told", async () => { + const after = vi.fn<() => void>(); + const stopThrowing = onCatalogChange(() => { + throw new Error("listener failure"); + }); + const stopAfter = onCatalogChange(after); + try { + const result = await refreshCatalog(answering(landed), true); + expect(result.zen.map((model) => model.id)).toContain( + "change-probe-model" + ); + expect(after).toHaveBeenCalledTimes(1); + } finally { + stopThrowing(); + stopAfter(); + } + }); + + it("the unsubscribe removes the listener: a later refresh does not reach it", async () => { + const before = catalogListenerCount(); + const listener = vi.fn<() => void>(); + const stop = onCatalogChange(listener); + expect(catalogListenerCount()).toBe(before + 1); + stop(); + expect(catalogListenerCount()).toBe(before); + await refreshCatalog(answering(landed), true); + expect(listener).not.toHaveBeenCalled(); + }); + + it("the generation changes on a landed refresh and on a reset, and only then", async () => { + const listener = vi.fn<() => void>(); + const stop = onCatalogChange(listener); + try { + const start = catalogGeneration(); + // A failed read and an empty payload land nothing. + await refreshCatalog(answering(undefined, 503), true); + await refreshCatalog(answering({}), true); + expect(catalogGeneration()).toBe(start); + expect(listener).not.toHaveBeenCalled(); + await refreshCatalog(answering(landed), true); + const afterRefresh = catalogGeneration(); + expect(afterRefresh).not.toBe(start); + expect(listener).toHaveBeenCalledTimes(1); + resetCatalogState(); + expect(catalogGeneration()).not.toBe(afterRefresh); + } finally { + stop(); + } + }); +}); diff --git a/test/catalog.test.ts b/test/catalog.test.ts index 3402a4a1..b1a349ca 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -17,20 +17,17 @@ import { OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, PROTOCOL_FOR_SDK, - ROUTE_FOR_PROTOCOL, + protocolForSdk, SESSION_HEADER, - UNSERVED_PLANE_SDKS, - UNSERVED_SDKS, enrichModelsResponse, findModelSpec, getLiveGoCatalog, getLiveZenCatalog, - internalRouteFor, isGoModelsListingUrl, isModelsListingUrl, isServableSdk, - modelsForSdk, parseModelsDevCatalog, + planeModelFor, patchFetch, refreshCatalog, resetCatalogState, @@ -243,60 +240,39 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(buckets.get("@ai-sdk/openai")?.length).toBe(31); expect(buckets.get("@ai-sdk/anthropic")?.length).toBe(17); - // And each of those SDKs maps to a protocol this plugin actually serves, so - // no carried model names a format with nowhere to go. + // 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 + // exception any more: pi's own data files it as completions (D16). for (const [npm, ids] of buckets) { - const protocol = PROTOCOL_FOR_SDK[npm]; - if (protocol === undefined) { - // `@ai-sdk/mistral` is the deliberate exception, and it is the only one: - // `isServableSdk` keeps it out of every picker, so it is carried only to - // be excluded on purpose rather than being absent by accident. - // `@ai-sdk/google` used to be here too; it now maps to a protocol the - // bridge serves, so it takes the branch below. - expect(isServableSdk(npm)).toBe(false); - expect(ids.length).toBeGreaterThan(0); - continue; - } - expect(ROUTE_FOR_PROTOCOL[protocol]).toBeDefined(); - expect(isServableSdk(npm)).toBe(true); + expect(protocolForSdk(npm), npm).toBeDefined(); + expect(isServableSdk(npm), npm).toBe(true); + expect(ids.length).toBeGreaterThan(0); } + expect(protocolForSdk("@ai-sdk/mistral")).toBe("openai-completions"); }); - it("serves every SDK-routed model from the route its SDK names", () => { - // The end-to-end shape of the split, on cold-start data: each plane's model - // count is exactly the shim's, and the two planes do not overlap. - const responses = modelsForSdk("@ai-sdk/openai").map((m) => m.id); - const anthropic = modelsForSdk("@ai-sdk/anthropic").map((m) => m.id); + it("serves every SDK-routed model on the protocol its SDK names", () => { + // The end-to-end shape of the split, on cold-start data: the union of both + // planes' models falls into disjoint protocol sets, and the flagship of + // each is on the shim, so a cold start can serve it. + const byProtocol = new Map>(); + for (const spec of [...OPENCODE_ZEN_CATALOG, ...OPENCODE_GO_CATALOG]) { + const protocol = protocolForSdk(spec.provider_npm) ?? "unserved"; + byProtocol.set( + protocol, + (byProtocol.get(protocol) ?? new Set()).add(spec.id) + ); + } + 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.filter((id) => anthropic.includes(id))).toEqual([]); - // The flagship of each plane is on the shim, so a cold start can serve it. expect(responses).toContain("muse-spark-1.2"); expect(anthropic).toContain("claude-sonnet-4-5"); }); - it("keeps the Go plane's models off the Zen-based internal routes", () => { - // `modelsForSdk` unions both planes by default, which is right for "what - // does this gateway serve". It is wrong for the routes this plugin mounts: - // they are Zen-based, and the Go plane names six models the Zen endpoint does - // not serve — `qwen3.8-max`, `minimax-m2.7`, `minimax-m3` and - // `qwen3.7-plus` among them, none of which carries an SDK override on Zen. - // Offering one resolves to "model not found" against the route's own base URL. - const zen = OPENCODE_ZEN_CATALOG; - for (const sdk of ["@ai-sdk/openai", "@ai-sdk/anthropic"]) { - expect(modelsForSdk(sdk, zen).length).toBeLessThan( - modelsForSdk(sdk).length - ); - } - expect(modelsForSdk("@ai-sdk/openai", zen).length).toBe(31); - expect(modelsForSdk("@ai-sdk/anthropic", zen).length).toBe(17); - // And the ones that would have leaked are genuinely Go-only. - const goOnly = ["muse-spark-1.3-contributor", "qwen3.7-plus"]; - for (const id of goOnly) { - expect(zen.some((s) => s.id === id)).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 rawGatewayPayload = { @@ -1057,7 +1033,8 @@ describe("catalog · the README's routing table", () => { const zen = counted(OPENCODE_ZEN_CATALOG); const go = counted(OPENCODE_GO_CATALOG); - const rows: { go: number; sdk: string; zen: number }[] = []; + const rows: { go: number; protocol: string; 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. @@ -1078,7 +1055,7 @@ describe("catalog · the README's routing table", () => { ) { continue; } - rows.push({ go: goCount, sdk, zen: zenCount }); + rows.push({ go: goCount, protocol: cells[2] ?? "", sdk, zen: zenCount }); } expect(rows.length).toBeGreaterThan(0); @@ -1090,6 +1067,18 @@ describe("catalog · the README's routing table", () => { expect(rows.map((row) => row.sdk).toSorted()).toEqual( [...new Set([...zen.keys(), ...go.keys()])].toSorted() ); + // The prose under the table restates the Go column's non-completions sum, + // and went stale once when a refresh moved the cells but not the sentence. + const prose = + /Go plane serves (\d+) models on shapes other than completions/u.exec( + text + ); + expect(prose, "the Go non-completions sentence is gone").not.toBeNull(); + expect(Number(prose?.[1])).toBe( + rows + .filter((row) => row.protocol !== "`openai-completions`") + .reduce((sum, row) => sum + row.go, 0) + ); }); it("states the same total it lists", async () => { @@ -1115,127 +1104,128 @@ describe("catalog · SDK coverage", () => { } } } - const classified = new Set([ - ...Object.keys(PROTOCOL_FOR_SDK), - ...UNSERVED_SDKS, - ]); + // 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, so it needs a route on ITS OWN plane: the Go plane's base URL - * is not Zen's, and a route carries exactly one of them. Checking the SDK alone - * - which is what the servability check does - cannot see the difference, so - * this walks (plane, SDK) pairs instead, and names every one that has no route. - * - * A knowingly unserved SDK is skipped: that is a decision (I5), not a gap. + * 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("has a route for every (plane, SDK) pair the catalogs contain", () => { + 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 model of catalog) { - const sdk = (model as { provider_npm?: string }).provider_npm; - if ( - sdk === undefined || - UNSERVED_SDKS.has(sdk) || - UNSERVED_PLANE_SDKS.has(`${plane} + ${sdk}`) - ) { - continue; + 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 (internalRouteFor(plane, model.id, sdk) === undefined) { - gaps.push(`${plane} + ${sdk}`); + if (onGo !== (plane === "opencode-go")) { + gaps.push(`${plane}/${spec.id}: ${model.baseUrl}`); } } } - expect([...new Set(gaps)].toSorted()).toEqual([]); + 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 - and a redirected call comes back with an internal route id, which - * is not a plane, which is what stops the redirect recursing. + * serve it. */ describe("routing · the plane half of the decision", () => { - it("sends the same SDK to a different route per plane", () => { - expect(internalRouteFor("opencode", "gpt-5.4", "@ai-sdk/openai")).toBe( - "opencode-responses" - ); - expect(internalRouteFor("opencode-go", "grok-4.7", "@ai-sdk/openai")).toBe( - "opencode-go-responses" - ); - expect( - internalRouteFor("opencode-go", "minimax-m3", "@ai-sdk/anthropic") - ).toBe("opencode-go-anthropic"); - }); - - it("refuses to redirect from anything that is not a plane", () => { - // An internal route id is where a redirected call arrives from, so treating - // it as a plane would loop. - expect( - internalRouteFor("opencode-responses", "gpt-5.4", "@ai-sdk/openai") - ).toBeUndefined(); - expect( - internalRouteFor("opencode-go-responses", "grok-4.7", "@ai-sdk/openai") - ).toBeUndefined(); - }); - - it("leaves a model with no protocol alone", () => { - expect( - internalRouteFor("opencode-go", "gemini-4-argon", "@ai-sdk/google") - ).toBe("opencode-go-google"); - expect(internalRouteFor("opencode-go", "grok-4.7")).toBeUndefined(); + 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")).toMatchObject({ + api: "google-generative-ai", + baseUrl: "https://opencode.ai/inference/go/google/v1beta", + }); }); }); /** - * The default format, which is the only thing the original route has to decide. - * - * A model that speaks the route's own protocol has no SDK of its own — the route's - * `api` IS its protocol — so the call stays where the user configured it. That is - * the whole of "handling the original opencode default format": there is nothing to - * redirect, and nothing to hide. Everything the plugin adds lives around this case, - * not in it. + * 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 stays where it is", () => { - it("never redirects a model with no SDK of its own", () => { - // `big-pickle` carries no `provider.npm`: completions is the default, and the - // declared route already speaks it. - expect(internalRouteFor("opencode", "big-pickle")).toBeUndefined(); - expect(internalRouteFor("opencode-go", "big-pickle")).toBeUndefined(); +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("redirects an id-only family the catalog does not describe", () => { - // A model nobody catalogued still cannot be sent as a chat completion when its - // family fixes the protocol by name — the measured behaviour, not a guess. - expect(internalRouteFor("opencode", "claude-unlisted-9")).toBe( - "opencode-anthropic" + 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(internalRouteFor("opencode", "gemini-unlisted-9")).toBe( - "opencode-google" + expect(servedOn("opencode", "gemini-unlisted-9").api).toBe( + "openai-completions" ); - }); - - it("leaves the route alone when the declared protocol is its own", () => { - // The `modelRoutes` override reaches the decision, and a declaration equal to - // the route's protocol means no move at all. expect( - internalRouteFor( - "opencode", - "some-model", - "@ai-sdk/anthropic", - "openai-completions" - ) - ).toBeUndefined(); + 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/composition.test.ts b/test/composition.test.ts index 9d89df1e..ee7832f8 100644 --- a/test/composition.test.ts +++ b/test/composition.test.ts @@ -28,37 +28,41 @@ import { describe, expect, it } from "vitest"; import { type CordisContext, apply, inject } from "./support/src.ts"; -/** The one service the plugin declares, shaped as the plugin reads it. */ -const llmService = () => ({ +/** The `llm` service, shaped as the plugin reads it, logging discovery registrations. */ +const llmService = (registered: string[] = []) => ({ listModels: async () => [], listProviders: () => [], registerAdapter: () => () => {}, - registerModelDiscovery: () => {}, + registerModelDiscovery: (ns: string) => { + registered.push(ns); + }, }); /** * Mount the plugin the way the Loader does. * - * `inject` is the plugin's OWN declaration, so cordis keeps it inactive until - * `llm` exists — exactly the ordering a real host provides and a hand-built - * context could not. + * `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; + readonly registered?: string[]; } = {} ) => { const ctx = new Context(); if (options.withLlm !== false) { - ctx.provide("llm", llmService() as never); + ctx.provide("llm", llmService(options.registered) as never); } let activated = false; const fiber = ctx.plugin({ inject, apply: (scope: unknown) => { activated = true; - apply(scope as CordisContext, options.config ?? {}); + return apply(scope as CordisContext, options.config ?? {}); }, }); await fiber.await?.(); @@ -69,17 +73,21 @@ const mount = async ( }; describe("composition · a real cordis Context", () => { - it("activates only once its declared service is provided", async () => { - // The reason `inject` is a declaration and not a convention: without `llm` - // cordis must NOT run the plugin, because everything the plugin installs - // decorates that service. - const without = await mount({ withLlm: false }); - expect(without.activated, "activated without its declared `llm`").toBe( - false - ); - - const withService = await mount(); - expect(withService.activated).toBe(true); + it("activates without `llm`, and what needs `llm` waits in a child scope until it is provided", async () => { + // The root declares nothing (`inject` is []), so its `internal/config` + // listener is in place before `llm` — and before pi-ai — exists. What does + // need `llm` is reached through `ctx.inject`, and runs only once cordis + // provides it. + expect(inject).toEqual([]); + const registered: string[] = []; + const without = await mount({ registered, withLlm: false }); + expect(without.activated, "the root waits for nothing").toBe(true); + expect(registered).toEqual([]); + without.ctx.provide("llm", llmService(registered) as never); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + expect(registered).toContain("opencode"); }); it("leaves the purge service reachable through ctx.get", async () => { diff --git a/test/config-values.test.ts b/test/config-values.test.ts index 0608de67..c5a8e2d6 100644 --- a/test/config-values.test.ts +++ b/test/config-values.test.ts @@ -29,16 +29,7 @@ const node = (value: unknown) => ({ describe("config-values: shared defaults", () => { it("exposes the marker and defaults both sides read", () => { expect(ALL_MODELS_MARKER).toBe("*"); - expect(DEFAULT_PROVIDERS).toEqual([ - "opencode", - "opencode-go", - "opencode-responses", - "opencode-anthropic", - "opencode-google", - "opencode-go-responses", - "opencode-go-anthropic", - "opencode-go-google", - ]); + expect(DEFAULT_PROVIDERS).toEqual(["opencode", "opencode-go"]); }); }); diff --git a/test/config.test.ts b/test/config.test.ts index 4a9335f3..68f5263d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -16,7 +16,6 @@ import { DEFAULT_KEY_SOURCE, ENDPOINT_FAMILIES, KEY_SOURCE_POLICIES, - type ModelOverrideConfig, type PluginConfig, isOpenCodeRequest, resolveConfig, @@ -30,16 +29,7 @@ afterEach(() => { describe("resolveConfig", () => { it("fills default providers, toggles, and debug flags", () => { const resolved = resolveConfig({}); - expect([...resolved.providers]).toEqual([ - "opencode", - "opencode-go", - "opencode-responses", - "opencode-anthropic", - "opencode-google", - "opencode-go-responses", - "opencode-go-anthropic", - "opencode-go-google", - ]); + expect([...resolved.providers]).toEqual(["opencode", "opencode-go"]); expect(resolved.debug).toBe(false); expect(resolved.debugFile).toBeUndefined(); expect(resolved.injectUserAgent).toBe(true); @@ -89,79 +79,6 @@ describe("resolveConfig", () => { expect(resolved.enrichModels).toBe(true); }); - it("resolves and validates maxContextWindow when specified", () => { - expect(resolveConfig({}).maxContextWindow).toBeUndefined(); - expect(resolveConfig({ maxContextWindow: 262_144 }).maxContextWindow).toBe( - 262_144 - ); - expect( - resolveConfig({ maxContextWindow: "131072" as unknown as number }) - .maxContextWindow - ).toBe(131_072); - expect( - resolveConfig({ maxContextWindow: -100 }).maxContextWindow - ).toBeUndefined(); - expect( - resolveConfig({ maxContextWindow: 0 }).maxContextWindow - ).toBeUndefined(); - expect( - resolveConfig({ maxContextWindow: "invalid" as unknown as number }) - .maxContextWindow - ).toBeUndefined(); - }); - - it("resolves and validates modelOverrides when specified", () => { - expect(resolveConfig({}).modelOverrides).toEqual({}); - const overrides = { - "gpt-5.4": { - contextWindow: 131_072, - cost: { cache_read: 0.1, input: 1.5, output: 8 }, - inputModalities: ["text" as const, "image" as const], - maxTokens: 32_000, - name: "GPT-5.4 Custom", - reasoningEfforts: ["none", "high"], - }, - }; - const resolved = resolveConfig({ modelOverrides: overrides }); - expect(resolved.modelOverrides["gpt-5.4"]).toEqual(overrides["gpt-5.4"]); - - // Test alias keys and edge cases - const aliasOverrides = { - "gemini-3.8-flash": { - context_window: 262_144, - cost: { cache_write: 0.2, input: 0.5, output: 2 }, - efforts: ["minimal", "medium"], - max_output_tokens: 16_384, - modalities: ["text", "image"], - }, - "invalid-entry": null, - }; - const resolvedAliases = resolveConfig({ - modelOverrides: aliasOverrides as unknown as Record< - string, - ModelOverrideConfig - >, - }); - expect(resolvedAliases.modelOverrides["gemini-3.8-flash"]).toEqual({ - contextWindow: 262_144, - cost: { cache_write: 0.2, input: 0.5, output: 2 }, - inputModalities: ["text", "image"], - maxTokens: 16_384, - reasoningEfforts: ["minimal", "medium"], - }); - expect(resolvedAliases.modelOverrides["invalid-entry"]).toBeUndefined(); - - // Invalid non-record input yields empty record - expect( - resolveConfig({ - modelOverrides: "invalid" as unknown as Record< - string, - ModelOverrideConfig - >, - }).modelOverrides - ).toEqual({}); - }); - it("preserves custom providers and configuration overrides", () => { const resolved = resolveConfig({ debug: true, @@ -172,7 +89,10 @@ describe("resolveConfig", () => { providers: ["custom-opencode", "opencode-dev"], userAgent: "my-custom-ua/1.0", }); + // The planes are always claimed (D19): a custom list adds to them. expect([...resolved.providers]).toEqual([ + "opencode", + "opencode-go", "custom-opencode", "opencode-dev", ]); @@ -188,22 +108,10 @@ describe("resolveConfig", () => { expect([...resolveConfig({ providers: [] }).providers]).toEqual([ "opencode", "opencode-go", - "opencode-responses", - "opencode-anthropic", - "opencode-google", - "opencode-go-responses", - "opencode-go-anthropic", - "opencode-go-google", ]); expect([...resolveConfig({ providers: [""] }).providers]).toEqual([ "opencode", "opencode-go", - "opencode-responses", - "opencode-anthropic", - "opencode-google", - "opencode-go-responses", - "opencode-go-anthropic", - "opencode-go-google", ]); }); @@ -226,16 +134,7 @@ describe("resolveConfig", () => { expect(defaults.injectProject).toBe(true); expect(defaults.freeModelMarker).toBe("free"); expect(defaults.sessionIdEnv).toBe("OPENCODE_SESSION_ID"); - expect([...defaults.providers]).toEqual([ - "opencode", - "opencode-go", - "opencode-responses", - "opencode-anthropic", - "opencode-google", - "opencode-go-responses", - "opencode-go-anthropic", - "opencode-go-google", - ]); + expect([...defaults.providers]).toEqual(["opencode", "opencode-go"]); const custom = resolveConfig({ freeModelMarker: " preview ", @@ -250,7 +149,11 @@ describe("resolveConfig", () => { expect(custom.injectProject).toBe(false); expect(custom.freeModelMarker).toBe("preview"); expect(custom.sessionIdEnv).toBe("MY_SESSION"); - expect([...custom.providers]).toEqual(["go-relay"]); + expect([...custom.providers]).toEqual([ + "opencode", + "opencode-go", + "go-relay", + ]); // Blank strings and empty lists fall back instead of disabling the knob. const blank = resolveConfig({ @@ -268,16 +171,7 @@ describe("resolveConfig", () => { expect(blank.injectProject).toBe(true); expect(blank.freeModelMarker).toBe("free"); expect(blank.sessionIdEnv).toBe("OPENCODE_SESSION_ID"); - expect([...blank.providers]).toEqual([ - "opencode", - "opencode-go", - "opencode-responses", - "opencode-anthropic", - "opencode-google", - "opencode-go-responses", - "opencode-go-anthropic", - "opencode-go-google", - ]); + expect([...blank.providers]).toEqual(["opencode", "opencode-go"]); }); }); @@ -296,6 +190,42 @@ describe("Config schema", () => { expect(resolveConfig(stale)).not.toHaveProperty("internalRoutes"); }); + it("accepts a stale row carrying modelOverrides, maxContextWindow and modelRoutes, and ignores them", () => { + // Removed in 1.x (D19): what a catalog row and the user's own model row + // already say. A deployment upgrading from 1.4 still carries them, and the + // host validates the row before the plugin loads, so an issue here would + // stop the plugin rather than retire three keys. + const stale = { + maxContextWindow: 1, + modelOverrides: {}, + modelRoutes: {}, + } as unknown as PluginConfig; + expect(() => Config(stale)).not.toThrow(); + const result = Config["~standard"].validate(stale); + expect(result).not.toBeInstanceOf(Promise); + expect((result as { issues?: unknown }).issues).toBeUndefined(); + const resolved = resolveConfig(stale); + expect(resolved).not.toHaveProperty("maxContextWindow"); + expect(resolved).not.toHaveProperty("modelOverrides"); + expect(resolved).not.toHaveProperty("modelRoutes"); + // The read is real: a live key resolves beside the stale ones. + expect(resolved.endpoint).toBe("auto"); + }); + + it("always claims both planes, whatever the row lists", () => { + // `providers` names the routes whose turns the plugin decorates; the two + // planes are the plugin's own routes, so a row cannot opt them out. + expect([...resolveConfig({ providers: ["x"] }).providers]).toEqual([ + "opencode", + "opencode-go", + "x", + ]); + expect([ + ...resolveConfig({ providers: ["opencode-go", "x", "opencode"] }) + .providers, + ]).toEqual(["opencode", "opencode-go", "x"]); + }); + it("validates an empty row to the documented defaults", () => { // The harness validates the patch row against this schema before the // plugin sees it, and the settings UI serves the namespace from it — so @@ -316,9 +246,6 @@ describe("Config schema", () => { "injectProject", "injectUserAgent", "keySource", - "maxContextWindow", - "modelOverrides", - "modelRoutes", "originClient", "providers", "readOpencodeStore", @@ -342,16 +269,7 @@ describe("Config schema", () => { injectUserAgent: true, keySource: "auto", originClient: "cli", - providers: new Set([ - "opencode", - "opencode-go", - "opencode-responses", - "opencode-anthropic", - "opencode-google", - "opencode-go-responses", - "opencode-go-anthropic", - "opencode-go-google", - ]), + providers: new Set(["opencode", "opencode-go"]), readOpencodeStore: false, sessionIdEnv: "OPENCODE_SESSION_ID", usageBaseURL: "https://opencode.ai/zen/go/v1", @@ -410,16 +328,7 @@ describe("Config schema", () => { }); describe("isOpenCodeRequest (endpoint differentiation)", () => { - const providers = new Set([ - "opencode", - "opencode-go", - "opencode-responses", - "opencode-anthropic", - "opencode-google", - "opencode-go-responses", - "opencode-go-anthropic", - "opencode-go-google", - ]); + const providers = new Set(["opencode", "opencode-go"]); it("identifies opencode.ai/zen endpoints", () => { expect( @@ -522,53 +431,3 @@ describe("isOpenCodeRequest (endpoint differentiation)", () => { ).toBe(false); }); }); - -describe("config · modelRoutes", () => { - it("reads api and baseURL, accepting the snake_case spelling too", () => { - // A deployment may write `base_url`; both spellings name the same thing, and - // the camelCase one wins when both are present. - // `base_url` is the spelling a deployment may write; the TYPE names only - // the camelCase one, so the raw config is widened here and nowhere else. - const raw = { - modelRoutes: { - "claude-sonnet-4-5": { api: "anthropic", baseURL: "https://a.example" }, - "gpt-5.4": { base_url: "https://b.example" }, - both: { - baseURL: "https://camel.example", - base_url: "https://snake.example", - }, - }, - }; - const config = resolveConfig(raw as never); - expect(config.modelRoutes["claude-sonnet-4-5"]).toEqual({ - api: "anthropic", - baseURL: "https://a.example", - }); - expect(config.modelRoutes["gpt-5.4"]).toEqual({ - baseURL: "https://b.example", - }); - expect(config.modelRoutes.both).toEqual({ - baseURL: "https://camel.example", - }); - }); - - it("drops an entry with no route rather than repairing it", () => { - // A typo must not silently pin a model to an endpoint nobody meant. - const config = resolveConfig({ - modelRoutes: { - empty: {}, - "wrong-case": { API: "anthropic" }, - wrongType: { api: 42, baseURL: 7 }, - notAnObject: "https://nope.example", - good: { api: "anthropic" }, - } as never, - }); - expect(Object.keys(config.modelRoutes)).toEqual(["good"]); - }); - - it("answers no overrides for a value that is not an object", () => { - for (const modelRoutes of [undefined, null, "routes", 42, []]) { - expect(resolveConfig({ modelRoutes } as never).modelRoutes).toEqual({}); - } - }); -}); diff --git a/test/deleted-settings.test.ts b/test/deleted-settings.test.ts index 02a84a71..5409320d 100644 --- a/test/deleted-settings.test.ts +++ b/test/deleted-settings.test.ts @@ -11,6 +11,12 @@ * * `internalRoutes` was removed in 1.x (D2): the per-protocol routes always * mount. `resolveRoutedKey` (D4) was exported and never called from `src/`. + * `modelOverrides`, `maxContextWindow` and `modelRoutes` went in P-R1 (D19): + * the plugin serves each plane itself, so a catalog row and the user's own + * model row say what they did. `hiddenModels` was documented and never read. + * The hidden routes the plugin used to mount (`INTERNAL_ROUTES`, + * `opencode-responses`, `opencode-anthropic`, `opencode-google` and their Go + * twins) went with them (D13): the planes are the only routes it claims. * * @module test/deleted-settings.test */ @@ -25,7 +31,30 @@ import { blankCommentLines } from "./support/strip-comments.ts"; const ROOT = join(import.meta.dirname, ".."); /** A deleted name, as a whole word. */ -const DELETED = /\b(?:internalRoutes|resolveRoutedKey|RoutedKeyDetails)\b/u; +const DELETED = + /\b(?:internalRoutes|resolveRoutedKey|RoutedKeyDetails|modelOverrides|maxContextWindow|modelRoutes|hiddenModels|INTERNAL_ROUTES)\b|\bopencode-(?:go-)?(?:responses|anthropic|google)\b/u; + +/** The deleted settings, one by one, for the places that list settings. */ +const DELETED_SETTINGS: readonly string[] = [ + "internalRoutes", + "maxContextWindow", + "modelOverrides", + "modelRoutes", +]; + +/** + * pi-ai's own provider-entry key of the same name. A plane entry is pi-ai's + * shape, and `utils/plane-profile.ts` mirrors pi-ai's checks of it, so that + * one module reads `modelOverrides` as pi-ai does; that is not the plugin's + * deleted setting, and it is the only exemption. + */ +const PI_AI_KEY_READERS: ReadonlyMap = new Map([ + ["src/utils/plane-profile.ts", /\bmodelOverrides\b/gu], +]); + +/** What a README may not name outside its "Changed in 1.x" section. */ +const DELETED_IN_README = + /\b(?:internalRoutes|modelOverrides|maxContextWindow|modelRoutes|hiddenModels|INTERNAL_ROUTES)\b|\bopencode-(?:go-)?(?:responses|anthropic|google)\b/u; /** Every `.ts`/`.tsx` under `dir`, at any depth, relative to `ROOT`. */ const sourcesUnder = (dir: string): string[] => @@ -57,19 +86,34 @@ const withoutSection = (text: string, heading: string): string => { }; describe("deleted settings", () => { - it("internalRoutes is gone from the schema, the resolver, the registry and the manifest", () => { - const schemaKeys = Object.keys(Config.dict ?? {}); - // The dict read is real: a live key is there. - expect(schemaKeys).toContain("endpoint"); - expect(schemaKeys).not.toContain("internalRoutes"); - expect(Object.keys(resolveConfig({}))).not.toContain("internalRoutes"); - expect(CONFIG_ONLY_FIELDS.map((f) => f.field)).not.toContain( - "internalRoutes" - ); - expect(read("cordis.patch.yml")).not.toMatch(/\binternalRoutes\b/u); + it.each(DELETED_SETTINGS)( + "%s is gone from the schema, the resolver, the registry and the manifest", + (setting) => { + const schemaKeys = Object.keys(Config.dict ?? {}); + // The dict read is real: a live key is there. + expect(schemaKeys).toContain("endpoint"); + expect(schemaKeys).not.toContain(setting); + expect(Object.keys(resolveConfig({}))).toContain("endpoint"); + expect(Object.keys(resolveConfig({}))).not.toContain(setting); + const fields = CONFIG_ONLY_FIELDS.map((f) => f.field); + expect(fields).toContain("providers"); + expect(fields).not.toContain(setting); + const manifest = read("cordis.patch.yml"); + expect(manifest).toMatch(/\bproviders\b/u); + expect(manifest).not.toMatch(new RegExp(`\\b${setting}\\b`, "u")); + } + ); + + it("the shipped row claims exactly the two planes", () => { + const manifest = read("cordis.patch.yml"); + const block = /providers:\s*\n((?:\s*-\s*\S+\n)+)/u.exec(manifest); + expect(block?.[1]).toBeDefined(); + expect( + [...(block?.[1] ?? "").matchAll(/-\s*(\S+)/gu)].map((m) => m[1]) + ).toEqual(["opencode", "opencode-go"]); }); - it("no source, door or README still names internalRoutes or resolveRoutedKey", () => { + it("no source, door or README still names a deleted setting, export or hidden route", () => { const files = [...sourcesUnder("src"), "test/support/src.ts"]; // Non-vacuous: the walk reaches nested modules. expect(files).toContain("src/config.ts"); @@ -78,10 +122,23 @@ describe("deleted settings", () => { blankComments(read(file)) .split("\n") .flatMap((line, index) => - DELETED.test(line) ? [`${file}:${String(index + 1)}`] : [] + DELETED.test(line.replace(PI_AI_KEY_READERS.get(file) ?? /(?!)/u, "")) + ? [`${file}:${String(index + 1)}`] + : [] ) ); expect(offenders).toEqual([]); + // The exemption is real, and narrow: the module does read pi-ai's key, and + // the exemption removes nothing else. + expect(read("src/utils/plane-profile.ts")).toMatch(/\bmodelOverrides\b/u); + expect( + DELETED.test( + "maxContextWindow modelOverrides".replace( + PI_AI_KEY_READERS.get("src/utils/plane-profile.ts") ?? /(?!)/u, + "" + ) + ) + ).toBe(true); // A README may name a removed setting only where it announces the removal. for (const [file, heading] of [ ["README.md", "## Changed in 1.x"], @@ -89,7 +146,9 @@ describe("deleted settings", () => { ] as const) { const text = read(file); expect(text).toContain(heading); - expect(withoutSection(text, heading)).not.toMatch(/\binternalRoutes\b/u); + // Non-vacuous: the section being exempted really names a removal. + expect(text).toMatch(DELETED_IN_README); + expect(withoutSection(text, heading)).not.toMatch(DELETED_IN_README); } }); }); diff --git a/test/describe-graft.test.ts b/test/describe-graft.test.ts new file mode 100644 index 00000000..c1e5e8e9 --- /dev/null +++ b/test/describe-graft.test.ts @@ -0,0 +1,143 @@ +/** + * The Settings graft (`src/host/describe-graft.ts`, D18): pi-ai's displayed + * value gets the committed plane entries back, and nothing else moves. + * + * @module test/describe-graft.test + */ +import { describe, expect, it } from "vitest"; + +import { + type CordisContext, + graftPlaneEntries, + installDescribeGraft, +} from "./support/src.ts"; + +describe("describe-graft · graftPlaneEntries", () => { + it("fills only the missing planes of the llm-pi-ai value and touches nothing else", () => { + const base = { providers: { deepseek: { b: 1 } } }; + const user = { providers: { opencode: { u: 1 } } }; + const other = { ns: "other", value: {} }; + const piAi = { + base, + ns: "llm-pi-ai", + revision: 3, + user, + value: { other: true, providers: { deepseek: {} } }, + }; + const input: unknown[] = [piAi, other]; + const entry = { apiKeyEnv: "OPENCODE_API_KEY" }; + const out = graftPlaneEntries(input, "llm-pi-ai", { + opencode: entry, + }) as [typeof piAi, typeof other]; + expect(out[0]?.value).toEqual({ + other: true, + providers: { deepseek: {}, opencode: entry }, + }); + expect(out[0]?.base).toBe(base); + expect(out[0]?.user).toBe(user); + expect(out[0]?.revision).toBe(3); + expect(out[1]).toBe(other); + expect(piAi.value.providers).not.toHaveProperty("opencode"); + }); + + it("leaves a plane the value already holds as it was, and a descriptor with nothing to add as the same object", () => { + const held = { mine: true }; + const descriptor = { + ns: "llm-pi-ai", + value: { providers: { opencode: held } }, + }; + const [out] = graftPlaneEntries([descriptor], "llm-pi-ai", { + opencode: { theirs: true }, + }) as [typeof descriptor]; + expect(out).toBe(descriptor); + expect(out.value.providers.opencode).toBe(held); + // With no committed entries there is nothing to graft. + const [same] = graftPlaneEntries([descriptor], "llm-pi-ai", {}) as [ + typeof descriptor, + ]; + expect(same).toBe(descriptor); + }); + + it("takes the namespace from its argument, so a row with another id works, and tolerates shapes it does not know", () => { + const [out] = graftPlaneEntries([{ ns: "llm", value: {} }], "llm", { + "opencode-go": { a: 1 }, + }) as [{ value: { providers: object } }]; + expect(out.value.providers).toEqual({ "opencode-go": { a: 1 } }); + expect(graftPlaneEntries("nope", "llm-pi-ai", {})).toBe("nope"); + const odd = [null, { ns: "llm-pi-ai" }, { ns: "llm-pi-ai", value: 5 }]; + expect(graftPlaneEntries(odd, "llm-pi-ai", { opencode: {} })).toEqual(odd); + }); +}); + +describe("describe-graft · installDescribeGraft", () => { + /** A stand-in whose `inject` hands over a settings scope, recording effects. */ + const host = (settings?: object) => { + const cleanups: (() => void)[] = []; + const ctx = { + inject: (_deps: string[], callback: (scope: unknown) => void) => { + callback({ + effect: (fn: () => unknown) => { + const cleanup = fn(); + if (typeof cleanup === "function") { + cleanups.push(cleanup as () => void); + } + }, + settings, + }); + }, + } as unknown as CordisContext; + return { cleanups, ctx }; + }; + + it("wraps describe through patchMethod, and its dispose restores the own descriptor exactly", () => { + const unwrapped = (): unknown => [ + { ns: "llm-pi-ai", value: { providers: {} } }, + ]; + const settings = { describe: unwrapped }; + const original = Reflect.getOwnPropertyDescriptor(settings, "describe"); + const { cleanups, ctx } = host(settings); + installDescribeGraft(ctx, { + committedEntries: () => ({ opencode: { a: 1 } }), + piAiNamespace: () => "llm-pi-ai", + }); + expect(settings.describe).not.toBe(unwrapped); + expect(settings.describe()).toEqual([ + { ns: "llm-pi-ai", value: { providers: { opencode: { a: 1 } } } }, + ]); + expect(cleanups).toHaveLength(1); + cleanups[0]?.(); + expect(Reflect.getOwnPropertyDescriptor(settings, "describe")).toEqual( + original + ); + }); + + it("stands down without a settings service, and on an inherited describe leaves no own property after dispose", () => { + const empty = host(); + installDescribeGraft(empty.ctx, { + committedEntries: () => ({}), + piAiNamespace: () => "llm-pi-ai", + }); + expect(empty.cleanups).toEqual([]); + class Settings { + describe(): unknown { + return []; + } + } + const settings = new Settings(); + const { cleanups, ctx } = host(settings); + installDescribeGraft(ctx, { + committedEntries: () => ({}), + piAiNamespace: () => "llm-pi-ai", + }); + expect(Object.hasOwn(settings, "describe")).toBe(true); + cleanups[0]?.(); + expect(Object.hasOwn(settings, "describe")).toBe(false); + // A context without `inject` installs nothing and does not throw. + expect(() => + installDescribeGraft({} as CordisContext, { + committedEntries: () => ({}), + piAiNamespace: () => "llm-pi-ai", + }) + ).not.toThrow(); + }); +}); diff --git a/test/e2e/dispatch-levels.e2e.ts b/test/e2e/dispatch-levels.e2e.ts index 05aca453..093d2d3c 100644 --- a/test/e2e/dispatch-levels.e2e.ts +++ b/test/e2e/dispatch-levels.e2e.ts @@ -2,25 +2,35 @@ * 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 from `ctx.llm.resolveModelInfo`, which - * `decorateModelInfo` fills from our catalog; dispatch resolves the model - * through the adapter's `modelOf`, which `decoratePlaneAdapters` fills, and - * validates the saved effort with `getSupportedThinkingLevels`. When the two - * disagreed, `glm-5.3-flash-free` offered `high` and `max` beside its name in - * the composer and answered UNSUPPORTED_REASONING_EFFORT for both - * (`fix(models): dispatch takes the levels the picker offers`). + * 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. * - * The unit suite pins the wrapper's logic against pi's rule copied into the - * test. This file imports the rule ITSELF — `getSupportedThinkingLevels` from - * the installed `@earendil-works/pi-ai`, the function `dsh-llm-pi-ai` carries - * its own copy of and validates with — runs both real decorators on both sides, - * and sweeps the whole shipped catalog on both planes: for every model, - * **every level the picker offers is a level the rule accepts**. A vendor move - * in the rule, or a catalog row the wrapper mishandles, fails here rather than - * in a user's turn. + * 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**. * - * Opt-in (`OPENCODE_E2E=1`), like every file in this suite. No network and no - * port: the value is the real rule and the real catalog, not a socket. + * The last 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. Each + * protocol walks up to three toggle-only candidates in catalog order, moving + * past one refused on entitlement or one the vendor withdrew (confirmed by a + * raw probe of the same id; unconfirmed, the turn fails). It skips without + * the plane's key, and, naming the protocol, when a protocol's every + * candidate was refused on entitlement or withdrawn. + * + * 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. */ import { readFile } from "node:fs/promises"; @@ -29,130 +39,93 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/models"; import { describe, expect, it } from "vitest"; import { + drainStream, + fakeCredentials, + mountTierA, + waitForPlaneOwner, +} from "../support/host.ts"; +import { isModelGoneMessage } from "../support/keyless-matrix.ts"; +import { + accessDisabled, + modelGone, + PLUGIN_PATH_FOR, + sendProbe, +} from "../support/live-probe.ts"; +import { + candidatesByProtocol, + type TurnVerdict, + walkPerProtocol, +} from "../support/live-walk.ts"; +import { + type CatalogModelSpec, + OPENCODE_FAMILY_API, OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, - decorateModelInfo, - decoratePlaneAdapters, - resolveConfig, + planeModelFor, + type Protocol, + protocolForSdk, + reasoningFor, } from "../support/src.ts"; const ENABLED = process.env.OPENCODE_E2E === "1"; -/** The two plane routes, and the catalog each one's models resolve from. */ +/** The two plane routes, the catalog and family map each resolves from, and its key. */ const PLANES = [ - { catalog: OPENCODE_ZEN_CATALOG, plane: "zen", route: "opencode" }, - { catalog: OPENCODE_GO_CATALOG, plane: "go", route: "opencode-go" }, + { + catalog: OPENCODE_ZEN_CATALOG, + family: OPENCODE_FAMILY_API.zen, + key: process.env.OPENCODE_API_KEY, + route: "opencode", + }, + { + catalog: OPENCODE_GO_CATALOG, + family: OPENCODE_FAMILY_API.go, + key: process.env.OPENCODE_GO_API_KEY, + route: "opencode-go", + }, ] as const; -type Adapter = Record; -type Registry = Map; - -interface Fixture { - /** - * The one object the adapter answers per id, so a case can assert the - * wrapper handed the adapter's own answer back — or filled a new one. - */ - readonly answers: Map>; - readonly llm: { - readonly adapters: Registry; - resolveModelInfo: (provider: string, model: string) => Promise; - }; -} - -/** - * A Host LLM service as the decorators find it: a public `adapters` map keyed - * by route, and a `resolveModelInfo` (the picker's read) answering the shape - * pi's plane adapter does for an id its installed catalog does not carry — - * `reasoning: false`, no level map. A foreign route rides along, because - * "only the plane routes" is half the contract. - */ -const fixtureFor = ( - seed: (model: string) => Record -): Fixture => { - const answers = new Map>(); - const answer = (model: string): Record => { - const existing = answers.get(model); - if (existing !== undefined) { - return existing; - } - const made = seed(model); - answers.set(model, made); - return made; - }; - const adapters: Registry = new Map(); - for (const route of [...PLANES.map((entry) => entry.route), "deepseek"]) { - adapters.set(route, { - adapter: { - modelOf: (_snapshot: unknown, _provider: string, model: string) => - answer(model), - }, - }); +/** 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 { - answers, - llm: { - adapters, - resolveModelInfo: async (_provider: string, model: string) => - answer(model), - }, - }; + return model; }; -/** The adapter's own `modelOf`, typed for the call dispatch makes. */ -const modelOfOn = ( - registry: Registry, - route: string -): ((provider: string, model: string) => unknown) => { - const method = registry.get(route)?.adapter.modelOf; - if (typeof method !== "function") { - throw new TypeError(`no modelOf on ${route}`); - } - return (provider: string, model: string): unknown => - (method as (snapshot: unknown, provider: string, model: string) => unknown)( - undefined, - provider, - 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 decorated answer is the shape `modelOf` returns and the rule - * reads two fields off it, so the cast is the seam. + * union; the served model is the shape it reads two fields off, so the cast + * is the seam. */ -const acceptedLevels = (answer: unknown): string[] => - getSupportedThinkingLevels(answer as never); - -/** The Effort row `decorateModelInfo` rendered, as level ids. */ -const offeredLevels = (info: unknown): string[] => { - if (typeof info !== "object" || info === null) { - return []; - } - const { reasoning } = info as { reasoning?: unknown }; - if (typeof reasoning !== "object" || reasoning === null) { - return []; - } - const { efforts } = reasoning as { efforts?: unknown }; - if (!Array.isArray(efforts)) { - return []; - } - const ids: string[] = []; - for (const effort of efforts) { - if (typeof effort === "object" && effort !== null) { - const { id } = effort as { id?: unknown }; - if (typeof id === "string") { - ids.push(id); - } - } - } - return ids; -}; +const acceptedLevels = (model: unknown): string[] => + getSupportedThinkingLevels(model as never); describe.skipIf(!ENABLED)( "the levels the picker offers are the levels dispatch accepts", () => { - const config = resolveConfig({ enrichModels: true }); - 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 @@ -179,206 +152,189 @@ describe.skipIf(!ENABLED)( ); }); - it("sweeps the whole catalog: every offered level is accepted", async () => { - const fixture = fixtureFor((model) => ({ - id: model, - name: model, - reasoning: false, - })); - const stopAdapters = decoratePlaneAdapters(fixture.llm as never, config); - const stopInfo = decorateModelInfo({ llm: fixture.llm } as never, config); + it("sweeps the whole catalog: the offered levels are exactly the accepted ones", () => { const problems: string[] = []; - try { - for (const { catalog, plane, route } of PLANES) { - let checked = 0; - for (const spec of catalog) { - const offered = offeredLevels( - await fixture.llm.resolveModelInfo(route, spec.id) + 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 (offered.length === 0) { - continue; - } + } + if (spec.reasoning === true) { checked += 1; - const accepted = acceptedLevels( - modelOfOn(fixture.llm.adapters, route)(route, spec.id) - ); - for (const level of offered) { - if (!accepted.includes(level)) { - problems.push(`${plane} ${spec.id}: dispatch refuses ${level}`); - } - } } - // Anti-vacuity: a plane whose rows stopped offering levels (a parser - // change, a catalog swap) must fail here, not pass by covering zero. - expect( - checked, - `no ${plane} model offered a thinking level` - ).toBeGreaterThan(0); } - } finally { - stopInfo?.(); - stopAdapters?.(); + // 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 turns that reported the bug", async () => { - const fixture = fixtureFor((model) => ({ - id: model, - name: model, - reasoning: false, - })); - const stopAdapters = decoratePlaneAdapters(fixture.llm as never, config); - const stopInfo = decorateModelInfo({ llm: fixture.llm } as never, config); - try { - const turns = [ - { id: "glm-5.3-flash-free", route: "opencode" }, - { id: "beam-free", route: "opencode" }, - { id: "beam-free", route: "opencode-go" }, - ] as const; - for (const { id, route } of turns) { - const offered = offeredLevels( - await fixture.llm.resolveModelInfo(route, id) - ); - // These two sat beside the model's name in the composer when the - // turn came back UNSUPPORTED_REASONING_EFFORT. - expect(offered, `${route} ${id} no longer offers high`).toContain( - "high" - ); - expect(offered, `${route} ${id} no longer offers max`).toContain( - "max" - ); - const accepted = acceptedLevels( - modelOfOn(fixture.llm.adapters, route)(route, id) - ); - expect( - offered.filter((level) => !accepted.includes(level)), - `${route} ${id}` - ).toEqual([]); - // The premise, on the object the adapter itself answered with: - // without the fill, dispatch takes nothing but `off`, which is why - // every offered level failed before the fix. - const raw = fixture.answers.get(id); - if (raw === undefined) { - throw new TypeError(`the fixture never answered for ${id}`); - } - expect(acceptedLevels(raw)).toEqual(["off"]); - } - } finally { - stopInfo?.(); - stopAdapters?.(); + 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); }); + } +); - it("fills only what the adapter refuses, and keeps every spelling it declares", () => { - const fixture = fixtureFor((model) => ({ - id: model, - name: model, - reasoning: true, - thinkingLevelMap: { high: "its-own-wire", low: null, xhigh: "X" }, - })); - const stop = decoratePlaneAdapters(fixture.llm as never, config); - try { - const answer = modelOfOn(fixture.llm.adapters, "opencode")( - "opencode", - "beam-free" - ); - const record = answer as { - reasoning?: unknown; - thinkingLevelMap?: Record; - }; - const map = record.thinkingLevelMap ?? {}; - expect(record.reasoning).toBe(true); - // Its own wire spelling survives: an answer that takes a level keeps it. - expect(map.high).toBe("its-own-wire"); - expect(map.xhigh).toBe("X"); - // A `null` entry is a REFUSAL, so it is filled from our catalog... - expect(map.low).toBe("low"); - // ...and so is `max`: for xhigh/max an absent entry is not a level at - // all, which is why the toggle rows gained them. - expect(map.max).toBe("max"); - // A base level it leaves out keeps the LEVEL'S OWN NAME on the wire — - // pi's rule for an absent base entry — so it is deliberately not filled. - expect("medium" in map).toBe(false); - } finally { - stop?.(); - } - }); +/** The most toggle-only candidates any one protocol declares in the live block. */ +const TOGGLE_CANDIDATES = 3; - it("hands back the adapter's own object when nothing is refused", () => { - const fixture = fixtureFor((model) => ({ - id: model, - name: model, - reasoning: true, - thinkingLevelMap: { low: "L", max: "M", xhigh: "X" }, - })); - const stop = decoratePlaneAdapters(fixture.llm as never, config); - try { - // cat-jam-free's offered levels are low, medium, high, xhigh and max: - // the declared map takes low/xhigh/max, and medium and high are base - // levels taken by omission — so there is nothing to fill. The call - // creates the fixture's answer; the wrapper must hand THAT object back - // rather than manufacture a new one. - const answer = modelOfOn(fixture.llm.adapters, "opencode")( - "opencode", - "beam-free" - ); - expect(answer).toBe(fixture.answers.get("beam-free")); - } finally { - stop?.(); - } - }); +/** One turn's own timeout in the live block. */ +const TURN_TIMEOUT_MS = 60_000; - it("takes a toggle model's xhigh and max, spelled `on`", () => { - const fixture = fixtureFor((model) => ({ - id: model, - name: model, - reasoning: false, - })); - const stop = decoratePlaneAdapters(fixture.llm as never, config); - try { - const answer = modelOfOn(fixture.llm.adapters, "opencode")( - "opencode", - "qwen3.6-plus" - ); - const map = ((answer as { thinkingLevelMap?: unknown }) - .thinkingLevelMap ?? {}) as Record; - // A toggle model's wire value is `on` at every level, and before the - // fix dispatch refused xhigh/max outright: an absent entry is not a - // level for those two. - expect(map.xhigh).toBe("on"); - expect(map.max).toBe("on"); - const accepted = acceptedLevels(answer); - expect(accepted).toContain("xhigh"); - expect(accepted).toContain("max"); - } finally { - stop?.(); - } - }); +/** + * Up to {@link TOGGLE_CANDIDATES} toggle-only models of each protocol on a + * plane, in catalog order: their efforts are named "On". + */ +const toggleModels = ( + plane: (typeof PLANES)[number] +): Map => + candidatesByProtocol( + plane.catalog, + (spec) => { + const protocol = protocolForSdk(spec.provider_npm); + const toggle = + protocol !== undefined && + reasoningFor(spec, protocol)?.efforts.some( + (effort) => effort.name === "On" + ) === true; + return toggle ? protocol : undefined; + }, + TOGGLE_CANDIDATES + ); + +/** An answer about the account, not the wire: no paid access, no funds, rate limited. */ +const ENTITLEMENT = + /\b40[23]\b|\b429\b|access is disabled|Insufficient account funds/iu; - it("wraps the plane routes only, and only ids the catalog can describe", () => { - const fixture = fixtureFor((model) => ({ - id: model, - name: model, - reasoning: false, - })); - const { adapters } = fixture.llm; - const planeBefore = adapters.get("opencode")?.adapter.modelOf; - const foreignBefore = adapters.get("deepseek")?.adapter.modelOf; - const stop = decoratePlaneAdapters(fixture.llm as never, config); - try { - expect(adapters.get("deepseek")?.adapter.modelOf).toBe(foreignBefore); - // An id in no catalog: the adapter's own answer, untouched. - const unknown = modelOfOn(adapters, "opencode")( - "opencode", - "not-in-any-catalog" +describe.skipIf(!ENABLED)( + "live · a toggle model's On is a wire value the gateway takes", + () => { + const keyed = (plane: (typeof PLANES)[number]): boolean => + plane.key !== undefined && plane.key.length > 0; + + it.for(PLANES)( + "$route: every toggle-only protocol accepts On through the plane adapter", + { timeout: 4 * TOGGLE_CANDIDATES * (TURN_TIMEOUT_MS + 20_000) + 60_000 }, + async (plane, context) => { + // Without the plane's key there is nothing to send: reported as + // skipped, never as a pass. + context.skip(!keyed(plane), "no key for this plane"); + const toggles = toggleModels(plane); + // Anti-vacuity: each plane carries at least one toggle-only model. + expect(toggles.size).toBeGreaterThan(0); + const specs = new Map( + [...toggles.values()].flat().map((spec) => [spec.id, spec]) ); - expect(unknown).toBe(fixture.answers.get("not-in-any-catalog")); - } finally { - stop?.(); + const ref = + plane.route === "opencode" + ? "OPENCODE_API_KEY" + : "OPENCODE_GO_API_KEY"; + const tier = await mountTierA({ + credentials: fakeCredentials({ refs: { [ref]: plane.key ?? "" } }), + plugin: {}, + providers: { + [plane.route]: { + apiKeyEnv: ref, + models: [...specs.keys()].map((id) => ({ id })), + }, + }, + }); + try { + await waitForPlaneOwner(tier.llm, [plane.route]); + const walk = await walkPerProtocol( + new Map( + [...toggles].map(([protocol, list]) => [ + protocol, + list.map((spec) => spec.id), + ]) + ), + async (protocol, id): Promise => { + // The id the picker's "On" carries, read from what the adapter + // answers rather than assumed. + const info = (await tier.llm.resolveModelInfo( + plane.route, + id + )) as { + reasoning?: { efforts?: { id: string; name: string }[] }; + }; + const on = info.reasoning?.efforts?.find( + (effort) => effort.name === "On" + )?.id; + expect(on, `${id} offers no On`).toBeDefined(); + const drained = await drainStream(tier.llm, plane.route, id, { + reasoningEffort: on, + signal: AbortSignal.timeout(TURN_TIMEOUT_MS), + }); + if ( + drained.error === undefined && + drained.finish?.kind !== "error" + ) { + return { kind: "ok" }; + } + const message = drained.failureMessage ?? drained.error ?? ""; + if (ENTITLEMENT.test(message)) { + return { kind: "entitlement" }; + } + if (isModelGoneMessage(message)) { + // Confirmed by hand at the URL the plugin's model names, with + // no reasoning field: the gateway answers "Model is + // unavailable" for a misrouted id too. + const spec = specs.get(id); + if (spec === undefined) { + throw new Error(`${id} is not a declared candidate`); + } + const served = servedModel(plane, spec); + const raw = await sendProbe({ + key: plane.key, + model: id, + protocol, + timeoutMs: 20_000, + url: `${served.baseUrl}${(PLUGIN_PATH_FOR[protocol] ?? "").replace("{model}", id)}`, + }); + if (modelGone(raw)) { + return { kind: "gone" }; + } + if (accessDisabled(raw)) { + return { kind: "entitlement" }; + } + return { + detail: `the turn says the model is gone (${message}), a raw probe of the same id answered ${raw.summary}`, + kind: "refused", + }; + } + return { detail: message, kind: "refused" }; + } + ); + expect(walk.refused).toEqual([]); + // A protocol no toggle candidate reached was not tested: a skip + // that names it, never a pass on the others. + context.skip( + walk.unreached.length > 0, + `unreached (every toggle candidate refused on entitlement or withdrawn): ${walk.unreached.join(", ")}` + ); + } finally { + await tier.dispose(); + } } - expect(adapters.get("opencode")?.adapter.modelOf).toBe(planeBefore); - expect(adapters.get("deepseek")?.adapter.modelOf).toBe(foreignBefore); - }); + ); } ); diff --git a/test/e2e/host-binary.e2e.ts b/test/e2e/host-binary.e2e.ts index e88041d8..f3d96aa8 100644 --- a/test/e2e/host-binary.e2e.ts +++ b/test/e2e/host-binary.e2e.ts @@ -10,25 +10,20 @@ * The probe records what the host serves and exits the process; this file reads * its JSON. * - * The assertions are TODAY's behaviour, read from the same `S0_TODAY` table the - * in-process characterization pins, so the binary and the harness cannot drift - * apart unnoticed. P-R1 flips the rows its PR names. The loader starts rows in - * parallel, so which of pi-ai and the plugin applies first is a race. The rows - * depend on whether the plugin's adapter decoration wrapped pi-ai's plane - * adapter, so the probe records that and the order it implies - * (`inferredOrder`), and the per-model rows follow it (`todayRowsFor`) rather - * than pinning a 70 ms margin. + * The assertions are the plugin's behaviour, read from the same `S0_TODAY` + * table the in-process characterization pins, so the binary and the harness + * cannot drift apart unnoticed. Since P-R1 the plugin owns both plane routes + * (D13): no row depends on which of pi-ai and the plugin applies first, so the + * per-model rows are `S0_TODAY.rows` in either order, and the listing and save + * rows no longer depend on which ids pi describes. * * Gated: it runs only with `DSH_HOST_E2E=1`, and needs `DSH_HOST_PREFIX`, a * directory whose `node_modules/@deepseek-ai/dsh` is the installed host. * `DSH_HOST_PI_CELL=pinned` (the default) also requires the pi * `host-versions.ts` names, with the harness patch, and asserts every row; - * `floating` accepts whatever pi the host resolved. It skips the rows that - * depend on which ids pi describes (the keyed listing and the per-model rows) - * and still asserts the save rows. Those assume the floating pi lacks at least - * one of `ZEN_UNDESCRIBED` and still describes every other declared Zen id, so - * a pi that learns all of them or retires a declared id turns them red by - * design — triage a red floating run as a pi change first. + * `floating` accepts whatever pi the host resolved, and skips the per-model + * rows (a newer pi's own entry can move a model's metadata); its probe JSON is + * the record. * * `DSH_HOST_PROBE_COPY` names a file to keep the probe's JSON in, for the CI * artifact; it is written whenever the probe wrote one, a failed run included. @@ -57,17 +52,14 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { HOST_VERSIONS } from "../support/host-versions.ts"; import { - type ApplyOrder, CHARACTERIZED_MODELS, CHARACTERIZED_PROFILE, PI_AI_PACKAGE, + PLANES, S0_TODAY, - SAVE_BLOCKED_BY_ANY_UNDESCRIBED, SAVE_CANDIDATE, - todayRowsFor, ZEN_UNDESCRIBED, } from "../support/host.ts"; -import { INTERNAL_ROUTES } from "../support/src.ts"; const ENABLED = process.env.DSH_HOST_E2E === "1"; const PREFIX = process.env.DSH_HOST_PREFIX ?? ""; @@ -91,14 +83,20 @@ const PROBE_BUNDLE = join(CHECKOUT, "test", "e2e", "host"); interface ProbeOutput { readonly fatal?: string; readonly fetchInnermost: boolean; - readonly routesSettled: boolean; - readonly planeAdapterDecorated: boolean; - /** Inferred from `planeAdapterDecorated`, not observed: see the probe. */ - readonly inferredOrder: ApplyOrder; + readonly planesSettled: boolean; + readonly piLoaded: { + fromHostPrefix: boolean; + path: string | null; + version: string; + } | null; readonly providers: string[]; readonly registryRoutes: string[]; readonly owners: Record; - readonly configurable: { provider: string; settingsNs: string }[]; + readonly configurable: { + provider: string; + settingsNs: string; + error?: string; + }[]; readonly listModels: Record; readonly listModelsAfter: Record; readonly modelInfo: Record< @@ -107,7 +105,18 @@ interface ProbeOutput { >; readonly callConfig: Record; readonly dispatch: Record; + /** Each turn's Google `generationConfig.thinkingConfig`, by `route/model`. */ + readonly thinking?: Record[]>; readonly describe: { user: string[]; value: string[] } | undefined; + readonly describeEntries: Record< + string, + { apiKeyEnv?: string; models: string[] } | null + >; + readonly modelsJoin: Record< + string, + { configured: boolean; apiKeyEnv?: string } + >; + readonly savedInFile?: boolean; readonly saveBlocked: { id: string; rejected?: string; accepted?: true }; readonly saveCleared?: { dropped: string[]; @@ -115,7 +124,11 @@ interface ProbeOutput { accepted?: true; }; readonly save: { id: string; rejected?: string; accepted?: true }; - readonly afterSave?: unknown; + readonly afterSave?: { + callConfig: string; + dispatch: { sent: string[]; finish: string }; + listed: unknown; + }; } /** The entries of a directory, or none when it cannot be read. */ @@ -212,6 +225,8 @@ const posix = (path: string): string => path.split(sep).join("/"); let home = ""; let out: ProbeOutput; +/** The version of the host prefix's own pi. */ +let hostPiVersion = ""; /** The booted host, kept so `afterAll` can stop it before removing HOME. */ let child: ChildProcess | undefined; @@ -239,6 +254,7 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { version: string; } ).version; + hostPiVersion = piVersion; console.info(`[e2e-host] cell ${CELL}: pi ${piVersion}`); expect(piCopies(join(PREFIX, "node_modules"))).toHaveLength(1); if (PINNED) { @@ -334,10 +350,11 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { cwd: home, env: { ...env, + PROBE_HOST_PREFIX: PREFIX, PROBE_MODELS: JSON.stringify(CHARACTERIZED_MODELS), PROBE_OUT: probeOut, PROBE_PI_PACKAGE: PI_AI_PACKAGE, - PROBE_ROUTES: JSON.stringify(INTERNAL_ROUTES), + PROBE_PLANES: JSON.stringify(PLANES), PROBE_SAVE: SAVE_CANDIDATE, PROBE_SAVE_DROP: JSON.stringify(ZEN_UNDESCRIBED), }, @@ -404,67 +421,81 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { } }); - it("measured with the probe innermost and the hidden routes settled", () => { + it("measured with the probe innermost and both planes settled on the plugin's adapter", () => { expect(out.fatal).toBeUndefined(); expect(out.fetchInnermost).toBe(true); - expect(out.routesSettled).toBe(true); + expect(out.planesSettled).toBe(true); }); - it("[INTENDED_CHANGE] registry: the planes are pi-ai's; the hidden routes are registered and not listed (P-R1 claims the planes)", () => { + it("the plane provider is built from the host's own pi, not the linked checkout's (both cells)", () => { + // The plugin is installed as a `link:` to this checkout, whose own + // development install holds a pi too; the plugin resolves the wrapper + // from the loader's base URL, so it reaches the host prefix's copy — the + // floating cell's floating pi included. + expect(out.piLoaded).toEqual({ + fromHostPrefix: true, + path: "node_modules/@earendil-works/pi-ai", + version: hostPiVersion, + }); + }); + + it("[BASELINE] registry: both planes are the plugin's PlaneAdapter, no hidden route exists, and Settings addresses the planes under pi-ai's namespace with no error (D13)", () => { expect(out.providers).toEqual( expect.arrayContaining(["opencode", "opencode-go"]) ); - for (const route of INTERNAL_ROUTES) { - expect(out.providers).not.toContain(route); - expect(out.registryRoutes).toContain(route); - } - expect(out.owners.opencode).toBe("PiAiAdapter"); - expect(out.owners["opencode-go"]).toBe("PiAiAdapter"); - for (const plane of ["opencode", "opencode-go"]) { - expect(out.configurable).toContainEqual( - expect.objectContaining({ provider: plane, settingsNs: "llm-pi-ai" }) - ); + expect(out.owners.opencode).toBe("PlaneAdapter"); + expect(out.owners["opencode-go"]).toBe("PlaneAdapter"); + expect( + out.registryRoutes.filter((route) => + /^opencode-(?:responses|anthropic|google|go-)/u.test(route) + ) + ).toEqual([]); + for (const plane of PLANES) { + const entry = out.configurable.find((row) => row.provider === plane); + expect(entry).toMatchObject({ provider: plane, settingsNs: "llm-pi-ai" }); + expect(entry?.error).toBeUndefined(); } }); - it("[INTENDED_CHANGE] keyless listing: every paid model is hidden before a key is captured", () => { - expect(out.listModels).toEqual(S0_TODAY.keylessListing); + it("[BASELINE] listing before any turn: every declared id is listed (D21)", () => { + expect(out.listModels).toEqual(S0_TODAY.listingBeforeTurn); }); - // These two depend on which ids pi describes, so they are pinned for the - // pi this repository pins; the floating cell skips them and its probe JSON - // is the record. - describe("rows that depend on which ids pi describes", () => { - it.skipIf(!PINNED)( - "[KNOWN_DEFECT] keyed listing: the gap ids are missing (pinned pi)", - () => { - expect(out.listModelsAfter).toEqual(S0_TODAY.keyedListing); - } - ); + it("[BASELINE] listing after a turn on each plane: every declared id is still listed (G1)", () => { + expect(out.listModelsAfter).toEqual(S0_TODAY.listingAfterTurn); + }); - it.skipIf(!PINNED)( - "resolveModelInfo, resolveCallConfig and the wire match the in-process rows for the decoration state the probe recorded (pinned pi)", - () => { - // The loader starts rows in parallel (F17), so whether the plugin's - // decoration wrapped pi-ai's plane adapter is a race: measured on the - // published binary it did not (the plugin applied about 70 ms first), - // which is no margin to pin. So the rows follow the recorded state, and - // `todayRowsFor` throws on a missing or unknown value. - console.info( - `[e2e-host] plane adapter decorated: ${String(out.planeAdapterDecorated)} (inferred order ${out.inferredOrder})` - ); - for (const [key, row] of Object.entries( - todayRowsFor(out.inferredOrder) - )) { - expect(out.modelInfo[key], key).toEqual(row.info); - expect(out.callConfig[key], key).toBe(row.callConfig); - expect(out.dispatch[key], key).toEqual({ - finish: row.finish, - sent: row.sent, - }); - } + // A floating pi's own entry can move a model's metadata, so these are + // pinned for the pi this repository pins; the floating cell's probe JSON is + // the record. + it.skipIf(!PINNED)( + "resolveModelInfo, resolveCallConfig and the wire match the in-process rows (pinned pi)", + () => { + for (const [key, row] of Object.entries(S0_TODAY.rows)) { + expect(out.modelInfo[key], key).toEqual(row.info); + expect(out.callConfig[key], key).toBe(row.callConfig); + expect(out.dispatch[key], key).toEqual({ + finish: row.finish, + sent: row.sent, + }); } - ); + expect(out.dispatch["opencode-go/gemini-4-argon"]?.sent).toEqual([ + "https://opencode.ai/inference/go/google/v1beta/models/gemini-4-argon:streamGenerateContent?alt=sse", + ]); + expect(out.callConfig["opencode-go/gemini-4-argon"]).toBe("ok"); + } + ); + + // The probe's turn chooses no effort, so a model the catalog describes by + // level is sent the level pi falls back to, never a budget: on the pinned + // pi through the plugin's Google wrapper (D16), on a newer pi through pi's + // own pattern. The live 400 was a `thinkingBudget` here. + it("[BASELINE] the Go Gemini level model is sent a thinking level and no thinkingBudget, through the shipped bundle (D16)", () => { + const sent = out.thinking?.["opencode-go/gemini-4-argon"] ?? []; + expect(sent.length).toBeGreaterThan(0); + for (const config of sent) { + expect(config).toEqual({ thinkingLevel: "LOW" }); + } }); it("[BASELINE] the settings page reads every plane the profile declares", () => { @@ -475,26 +506,40 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { ]); }); - it("[KNOWN_DEFECT] a stored gap id blocks saving any new id on its plane", () => { - // pi-ai's strict check names the FIRST stored id the installed pi cannot - // describe. The pinned pi lacks gpt-5.3-codex-spark, so it is named; a - // floating pi may have learned it, and then names the next undescribed id - // — so the floating cell asserts only that one of them blocks the save. - expect(out.saveBlocked.rejected).toMatch( - PINNED ? S0_TODAY.saveBlockedByStoredGap : SAVE_BLOCKED_BY_ANY_UNDESCRIBED - ); + it("[BASELINE] the Settings graft shows each plane's own entry, and the Models page reads both planes configured under their apiKeyEnv (D18)", () => { + expect(out.describeEntries).toEqual({ + opencode: { + apiKeyEnv: "OPENCODE_API_KEY", + models: CHARACTERIZED_MODELS.opencode, + }, + "opencode-go": { + apiKeyEnv: "OPENCODE_GO_API_KEY", + models: CHARACTERIZED_MODELS["opencode-go"], + }, + }); + expect(out.modelsJoin.opencode).toEqual({ + apiKeyEnv: "OPENCODE_API_KEY", + configured: true, + }); + expect(out.modelsJoin["opencode-go"]).toEqual({ + apiKeyEnv: "OPENCODE_GO_API_KEY", + configured: true, + }); + }); + + it("[BASELINE] a stored gap id blocks no save: the catalog-unknown id is accepted on the first try (D15)", () => { + expect(out.saveBlocked).toEqual({ accepted: true, id: SAVE_CANDIDATE }); + expect(out.saveCleared).toBeUndefined(); }); - it("[KNOWN_DEFECT] a catalog-unknown id cannot be saved, once the plane stores no gap id", () => { - // Clearing only ZEN_UNDESCRIBED assumes the installed pi still describes - // the other opencode ids the profile declares. The floating cell asserts - // it too: a pi that retires one of them would fail here, and that is a - // retirement worth a red nightly rather than a row to skip. - expect(out.saveCleared).toEqual({ - accepted: true, - dropped: [...ZEN_UNDESCRIBED], + it("[BASELINE] a catalog-unknown id saves to the file, is listed, validates and is sent as completions on the modern base (D15, D16)", () => { + expect(out.save).toEqual({ accepted: true, id: SAVE_CANDIDATE }); + expect(out.savedInFile).toBe(true); + expect(out.afterSave?.listed).toContain(SAVE_CANDIDATE); + expect(out.afterSave?.callConfig).toBe("ok"); + expect(out.afterSave?.dispatch).toEqual({ + finish: "AUTH", + sent: ["https://opencode.ai/inference/openai/v1/chat/completions"], }); - expect(out.save.rejected).toMatch(S0_TODAY.saveRejectedUnknown); - expect(out.afterSave).toBeUndefined(); }); }); diff --git a/test/e2e/host/probe-plugin.mjs b/test/e2e/host/probe-plugin.mjs index 163a07e3..c6d945a1 100644 --- a/test/e2e/host/probe-plugin.mjs +++ b/test/e2e/host/probe-plugin.mjs @@ -9,10 +9,11 @@ * the process. `test/e2e/host-binary.e2e.ts` reads the file. * * It never reaches the network: `fetch` is replaced at module load, so every - * request to `opencode.ai` or `models.opencode.ai` is recorded (URL and model - * only — never a header, so never a key) and answered with a 401, and every - * other host passes through untouched. What it writes is URLs, model ids, - * verdicts and host error messages — never a header or a key value — and + * request to `opencode.ai` or `models.opencode.ai` is recorded (URL, model and + * a Google body's `generationConfig.thinkingConfig` only — never a header, so + * never a key) and answered with a 401, and every other host passes through + * untouched. What it writes is URLs, model ids, thinking configs, verdicts and + * host error messages — never a header or a key value — and * every error message is cut to {@link MESSAGE_LIMIT} characters, since one * could name a path on the runner. * @@ -21,33 +22,61 @@ * - `PROBE_OUT`: where to write the JSON; * - `PROBE_MODELS`: `{ "": ["", …] }`, the ids to ask about; * - `PROBE_SAVE`: an id to append to `opencode`'s models through the config - * editor, the way the Settings page saves one. It is tried three times over: - * onto the plane as booted (`saveBlocked`), then — after the ids in - * `PROBE_SAVE_DROP` are removed from the plane (`saveCleared`) — once more - * (`save`), so the last answer is about the id itself and not about a gap id - * the plane already stores; - * - `PROBE_SAVE_DROP`: `["", …]`, the ids to remove first; - * - `PROBE_ROUTES`: `["", …]`, the hidden routes to wait for, which the - * e2e passes from `INTERNAL_ROUTES` so the probe holds no copy of them; + * editor, the way the Settings page saves one. It is tried onto the plane as + * booted (`saveBlocked`); only if that is refused are the ids in + * `PROBE_SAVE_DROP` removed (`saveCleared`) and the id tried once more + * (`save`), so the last answer is about the id itself; + * - `PROBE_SAVE_DROP`: `["", …]`, the ids to remove on a refusal; + * - `PROBE_PLANES`: `["", …]`, the plane routes to wait for until an + * adapter other than pi-ai's serves each (the plugin's own); * - `PROBE_PI_PACKAGE`: the package name pi-ai's loader entry carries; - * - `PROBE_SETTLE_MS`: how long to wait for the plugin's hidden routes - * (default 30000). + * - `PROBE_HOST_PREFIX`: the host's install prefix. The probe records which pi + * the plugin's plane provider was built from RELATIVE to it, so the JSON + * never holds an absolute path, and says whether it is inside it at all; + * - `PROBE_SETTLE_MS`: how long to wait for the planes (default 30000). * * @module test/e2e/host/probe-plugin */ -import { writeFileSync } from "node:fs"; +import { readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { isAbsolute, relative, sep } from "node:path"; export const name = "dsh-host-probe"; const OUT = process.env.PROBE_OUT; const SETTLE_MS = Number(process.env.PROBE_SETTLE_MS ?? "30000"); const GATEWAY_HOSTS = new Set(["opencode.ai", "models.opencode.ai"]); -/** The routes today's plugin mounts behind the planes. */ -const HIDDEN_ROUTES = JSON.parse(process.env.PROBE_ROUTES ?? "[]"); +/** The plane routes the plugin owns. */ +const PLANES = JSON.parse(process.env.PROBE_PLANES ?? "[]"); const PI_AI_PACKAGE = process.env.PROBE_PI_PACKAGE ?? ""; /** The longest error message written: enough to triage, too short for a dump. */ const MESSAGE_LIMIT = 500; +const HOST_PREFIX = process.env.PROBE_HOST_PREFIX ?? ""; + +/** + * Where the plane provider's pi came from, relative to the host prefix: the + * plane adapter's class carries the root and version it was built from. A + * root outside the prefix is reported as such, never as a path. + */ +const piLoadedFrom = (adapter) => { + const hostPi = adapter?.constructor?.hostPi; + if (hostPi === undefined || typeof hostPi.root !== "string") { + return null; + } + let path; + try { + path = relative(realpathSync(HOST_PREFIX), realpathSync(hostPi.root)); + } catch { + path = ".."; + } + const inside = path.length > 0 && !path.startsWith("..") && !isAbsolute(path); + return { + fromHostPrefix: inside, + path: inside ? path.split(sep).join("/") : null, + version: hostPi.version, + }; +}; + /** An error's message, cut to {@link MESSAGE_LIMIT}. */ const messageOf = (error) => String(error?.message ?? error).slice(0, MESSAGE_LIMIT); @@ -77,25 +106,39 @@ const hostOf = (url) => { } }; -const modelOf = async (input, init) => { +/** A request's JSON body, or `null` where it has none. */ +const bodyOf = async (input, init) => { let body = init?.body; if (body === undefined && input instanceof Request) { body = await input.clone().text(); } try { - const { model } = JSON.parse(String(body)); - return typeof model === "string" ? model : null; + return JSON.parse(String(body)); } catch { return null; } }; +/** + * What one gateway request is recorded as: its URL, the model its body names, + * and — on the Google protocol, whose body names no model — the thinking + * config the body carries (the D16 wire: a level, or pi's budget). + */ +const recordOf = async (input, init, url) => { + const body = await bodyOf(input, init); + const model = typeof body?.model === "string" ? body.model : null; + const thinkingConfig = body?.generationConfig?.thinkingConfig; + return thinkingConfig === undefined + ? { model, url } + : { model, thinkingConfig, url }; +}; + const probeFetch = async (input, init) => { const url = urlOf(input); if (!GATEWAY_HOSTS.has(hostOf(url))) { return passThrough(input, init); } - requests.push({ model: await modelOf(input, init), url }); + requests.push(await recordOf(input, init, url)); return Response.json( { error: { message: "probe: offline", type: "AuthError" } }, { status: 401 } @@ -204,6 +247,7 @@ const saveSequence = async (llm, editor, saveId, drop) => { models: [...(plane.models ?? []), { id: saveId }], }); const blocked = await editZen(editor, entry, append); + const document = editor.documentPath; let out = { saveBlocked: { id: saveId, ...blocked } }; let saved = blocked; if (blocked.rejected !== undefined) { @@ -222,8 +266,15 @@ const saveSequence = async (llm, editor, saveId, drop) => { const call = await settle(() => llm.resolveCallConfig({ model: saveId, provider: "opencode" }) ); + let savedInFile; + try { + savedInFile = readFileSync(document, "utf8").includes(saveId); + } catch { + savedInFile = false; + } return { ...out, + savedInFile, afterSave: { callConfig: call.error?.code ?? "ok", dispatch: await turn(llm, "opencode", saveId), @@ -233,17 +284,19 @@ const saveSequence = async (llm, editor, saveId, drop) => { }; const measure = async (ctx) => { - if (HIDDEN_ROUTES.length === 0 || PI_AI_PACKAGE.length === 0) { - throw new Error("probe: PROBE_ROUTES and PROBE_PI_PACKAGE must be set"); + if (PLANES.length === 0 || PI_AI_PACKAGE.length === 0) { + throw new Error("probe: PROBE_PLANES and PROBE_PI_PACKAGE must be set"); } await ctx.get("loader")?.await?.(); const llm = required(ctx, "llm"); const registry = () => llm.adapters; + /** Whether the plugin's adapter, not pi-ai's, serves a plane. */ + const claimed = (route) => { + const owner = registry().get(route)?.adapter?.constructor.name; + return owner !== undefined && owner !== "PiAiAdapter"; + }; const until = Date.now() + SETTLE_MS; - while ( - HIDDEN_ROUTES.some((route) => !registry().has(route)) && - Date.now() < until - ) { + while (!PLANES.every(claimed) && Date.now() < until) { await sleep(100); } const ids = JSON.parse(process.env.PROBE_MODELS ?? "{}"); @@ -255,6 +308,7 @@ const measure = async (ctx) => { provider: entry.provider, settingsNs: entry.settingsNs, settingsPath: entry.settingsPath, + ...(entry.error === undefined ? {} : { error: messageOf(entry.error) }), })), fetchInnermost: globalThis.fetch !== probeFetch, owners: Object.fromEntries( @@ -263,23 +317,11 @@ const measure = async (ctx) => { row.adapter.constructor.name, ]) ), - // Whether the plugin's adapter decoration (decoratePlaneAdapters in - // src/models-discovery.ts) wrapped pi-ai's plane adapter. It wraps only - // the adapters registered when its effect ran, so the apply order is - // INFERRED from it (`inferredOrder`) — not observed: a pi-ai restart after - // the plugin applied would read as plugin-first. The per-model rows depend - // on the decoration, not on the order as such, so this is what they need. - planeAdapterDecorated: Object.hasOwn( - registry().get("opencode")?.adapter ?? {}, - "resolveModel" - ), + piLoaded: piLoadedFrom(registry().get(PLANES[0])?.adapter), + planesSettled: PLANES.every(claimed), providers: llm.listProviders().map((provider) => provider.id), registryRoutes: [...registry().keys()], - routesSettled: HIDDEN_ROUTES.every((route) => registry().has(route)), }; - registered.inferredOrder = registered.planeAdapterDecorated - ? "pi-ai-first" - : "plugin-first"; const asked = await askAbout(llm, ids); const zenNs = registered.configurable.find( (entry) => entry.provider === "opencode" @@ -288,9 +330,18 @@ const measure = async (ctx) => { llm.discoverModels(zenNs, { provider: "opencode" }) ); const dispatch = {}; + const thinking = {}; for (const [route, models] of Object.entries(ids)) { for (const model of models) { + const before = requests.length; dispatch[`${route}/${model}`] = await turn(llm, route, model); + const sent = requests + .slice(before) + .filter((request) => request.thinkingConfig !== undefined) + .map((request) => request.thinkingConfig); + if (sent.length > 0) { + thinking[`${route}/${model}`] = sent; + } } } const listModelsAfter = {}; @@ -298,9 +349,49 @@ const measure = async (ctx) => { const listed = await settle(() => llm.listModels(route)); listModelsAfter[route] = listed.ok?.map((model) => model.id) ?? listed; } - const piDescriptor = required(ctx, "settings") + const settings = required(ctx, "settings"); + const piDescriptor = settings .describe() .find((descriptor) => descriptor.ns === "llm-pi-ai"); + // What Settings reads per plane: the profile's apiKeyEnv and model ids + // (`describeEntries`), and the Models page's join (`modelsJoin`) — the + // directory entry looked up in the redacted describe at its settingsPath. + const describeEntries = {}; + for (const plane of PLANES) { + const entry = piDescriptor?.value?.providers?.[plane]; + describeEntries[plane] = + entry === undefined + ? null + : { + apiKeyEnv: entry.apiKeyEnv, + models: (entry.models ?? []).map((model) => model.id), + }; + } + const redacted = settings.describe({ redactSecrets: true }); + const modelsJoin = {}; + for (const entry of llm.listConfigurableProviders()) { + if (!PLANES.includes(entry.provider)) continue; + const view = redacted.find( + (namespace) => namespace.ns === entry.settingsNs + ); + let profile = view?.value; + for (const key of entry.settingsPath) { + profile = + typeof profile === "object" && profile !== null + ? profile[key] + : undefined; + } + const ref = + typeof profile === "object" && profile !== null + ? profile.apiKeyEnv + : undefined; + modelsJoin[entry.provider] = { + apiKeyEnv: typeof ref === "string" && ref.length > 0 ? ref : undefined, + configured: + view !== undefined && + (entry.settingsPath.length === 0 || profile !== undefined), + }; + } const editor = required(ctx, "configEditor"); const saveId = process.env.PROBE_SAVE ?? ""; const drop = JSON.parse(process.env.PROBE_SAVE_DROP ?? "[]"); @@ -317,9 +408,12 @@ const measure = async (ctx) => { user: Object.keys(piDescriptor.user?.providers ?? {}), value: Object.keys(piDescriptor.value?.providers ?? {}), }, + describeEntries, discovery: discovered.ok ? { count: discovered.ok.length } : discovered, dispatch, + modelsJoin, listModelsAfter, + thinking, }; }; diff --git a/test/e2e/keyless-matrix.e2e.ts b/test/e2e/keyless-matrix.e2e.ts index c803deef..9b5e8810 100644 --- a/test/e2e/keyless-matrix.e2e.ts +++ b/test/e2e/keyless-matrix.e2e.ts @@ -14,12 +14,9 @@ * - every one of the sixteen cells has a model and was SENT, and every answer * is one the matrix can read — an empty or reshaped catalog fails here * rather than measuring nothing; - * - no cell today's plugin dispatches to with the default endpoint (`auto`, - * an alias of `modern`; `TODAY_DISPATCH`: completions on the legacy family, - * the hidden routes' Responses, Messages and Google on the modern one) - * answers "path gone"; - * - nor do the modern completions cells (`NEXT_DISPATCH`), where P-R1 moves - * completions: a forward guard, labelled as such when it fails. + * - no cell the plugin dispatches to with the default endpoint (`auto`, an + * alias of `modern`; `TODAY_DISPATCH`: all four protocols on the modern + * family, on both planes) answers "path gone". * * The other legacy verdicts are recorded, not asserted: P-R2 decides the * family from them. They include the cells an `endpoint: legacy` user @@ -48,7 +45,6 @@ import { type KeylessVerdict, keylessMatrix, type MatrixCell, - NEXT_DISPATCH, TODAY_DISPATCH, } from "../support/keyless-matrix.ts"; import { MODELS_OPENCODE_URL, parseModelsDevCatalog } from "../support/src.ts"; @@ -171,13 +167,6 @@ describe.skipIf(!LIVE || MIRRORED)( goneToday, `a path today's plugin dispatches to is gone: ${JSON.stringify(goneToday)}` ).toEqual([]); - const goneNext = rows.filter( - (row) => NEXT_DISPATCH.has(row.cell) && row.verdict === "path-gone" - ); - expect( - goneNext, - `forward guard: a path P-R1 moves completions to is gone: ${JSON.stringify(goneNext)}` - ).toEqual([]); }, 16 * 2 * (PROBE_TIMEOUT_MS + RETRY_AFTER_MS) + PROBE_TIMEOUT_MS ); diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index d699fef4..a6c87d9c 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -1,14 +1,15 @@ /** * Live protocol-routing E2E — the one thing a stub provably cannot prove. * - * The reason this plugin mounts a second `llm-pi-ai` route is a claim about the - * **vendor**: a model's wire format is named by its `provider_npm` on models.dev, - * and a model carried on the wrong route is served an endpoint that does not - * speak its format. Unit tests can only check that our table maps an SDK to a - * route — they read the table we wrote, so a vendor that reassigns a model's SDK - * still ships green. Two rounds of real bugs got through exactly that way: nine - * models mis-routed from a stale shim, and three more from reading the wrong - * PLANE's SDK. Neither was visible to a unit test. + * The reason the plane adapter dispatches each model on its catalog protocol + * (D16) is a claim about the **vendor**: a model's wire format is named by its + * `provider_npm` on models.dev, and a model sent on the wrong protocol is + * served an endpoint that does not speak its format. Unit tests can only check + * that our table maps an SDK to a protocol — they read the table we wrote, so + * a vendor that reassigns a model's SDK still ships green. Two rounds of real + * bugs got through exactly that way: nine models mis-routed from a stale shim, + * and three more from reading the wrong PLANE's SDK. Neither was visible to a + * unit test. * * So this file asks the gateway directly. * @@ -44,40 +45,90 @@ * SKIP in that situation instead of failing, because the account's entitlement * is not this plugin's contract. * + * **Every probe goes where the plugin sends.** The URL is the base the plane + * adapter's own model object names (`planeModelFor`, D16) for the served + * protocol, and the plane's base for the same protocol on the default family + * for a negative control — the modern `/inference/…` family since P-R1, Go's + * Google included. So a model that the gateway serves on the legacy `/zen` + * family but refuses on the one the plugin dispatches to turns a case red. + * Probing the legacy family per protocol is P-R2's record. + * + * **The last block sends through the plugin's own provider.** The probes above + * write their bodies and headers by hand, which is what makes them an exact + * oracle for the vendor; they are not the request a user's turn sends. So one + * paid model per protocol per plane — Go's Gemini on `/inference/go/google/v1beta` + * included — goes through the plane adapter on a tier-A host + * (`test/support/host.ts`): pi's own protocol implementation, its credential + * header, the session header and the fetch patch, against the live gateway. + * Each protocol walks up to three paid candidates in catalog order and stops + * at the first turn that says something about the wire: a turn refused on + * entitlement moves on, and so does one answered that the vendor withdrew the + * model (`410`, or `400` "Model is unavailable") once a raw probe of the same + * id at the URL the plugin should use confirms it. If the raw probe does not, + * the turn fails, because the gateway gives that answer for a misrouted id + * too. Any other error finish fails; a protocol no candidate reached makes + * the case skip with that protocol named, never pass on the others. + * * Opt-in via `OPENCODE_E2E=1`. The base is overridable so this can be pointed at - * a mirror or a local stub. + * a mirror or a local stub: an override is used for every protocol with the + * legacy family's paths, as before. * * OPENCODE_E2E=1 OPENCODE_API_KEY=… pnpm run test:e2e */ +import { usesGoogleThinkingLevel } from "@earendil-works/pi-ai/api/google-shared"; import { describe, expect, it } from "vitest"; import { - GOOGLE_INTERNAL_ROUTE, + drainStream, + fakeCredentials, + mountTierA, + waitForPlaneOwner, +} from "../support/host.ts"; +import { isModelGoneMessage } from "../support/keyless-matrix.ts"; +import { + accessDisabled, + AUTH_FOR, + modelGone, + PATH_FOR, + PLUGIN_PATH_FOR, + type Probe, + sendProbe, +} from "../support/live-probe.ts"; +import { + candidatesByProtocol, + type TurnVerdict, + walkPerProtocol, +} from "../support/live-walk.ts"; +import { + baseUrlFor, + GO_GOOGLE_BASE_URL, GOOGLE_SDK, + OPENCODE_FAMILY_API, OPENCODE_GO_CATALOG, - OPENCODE_PATCH_USER_AGENT, OPENCODE_ZEN_CATALOG, PROTOCOL_FOR_SDK, findModelSpec, findModelSpecOn, - internalRouteFor, isServableSdk, + planeModelFor, + usesCatalogThinkingLevels, } from "../support/src.ts"; const LIVE = process.env.OPENCODE_E2E === "1"; const ZEN_KEY = process.env.OPENCODE_API_KEY; -const ZEN_BASE = - process.env.OPENCODE_ZEN_BASE_URL ?? "https://opencode.ai/zen/v1"; - /** * 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 = process.env.OPENCODE_GO_API_KEY; -const GO_BASE = - process.env.OPENCODE_GO_BASE_URL ?? "https://opencode.ai/zen/go/v1"; + +/** 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, +}; /** Generous: these cross the public internet from CI. */ // Deliberately SHORTER than the suite's `testTimeout` (30s), because the two @@ -96,151 +147,61 @@ const TIMEOUT_MS = 20_000; */ const FANOUT_BUDGET_MS = 240_000; -/** - * What a model with no SDK gets: nothing is redirected, so it stays on the route - * the user configured, and that route speaks completions. - */ +/** What a model with no SDK of its own is served on (D16). */ const DEFAULT_PROTOCOL = "openai-completions"; -/** The path each protocol uses against the Zen base. */ -const PATH_FOR: Readonly> = { - "anthropic-messages": "/messages", - // The Gemini path carries the MODEL in the URL, not the body, so the probe - // substitutes the `{model}` placeholder the same way the wire does. - "google-generative-ai": "/models/{model}:streamGenerateContent?alt=sse", - "openai-completions": "/chat/completions", - "openai-responses": "/responses", -}; - /** - * Each endpoint's OWN auth convention. - * - * `/messages` is the Anthropic Messages shape, which reads `x-api-key` and - * ignores `Authorization`. Sending the Bearer form there answers - * `401 Missing API key` — the header is simply not read — so a probe that used - * one convention everywhere would conclude the model was unreachable. + * The URL the plugin sends `protocol` for `model` to on `plane`: the base the + * served model names when `protocol` is the one it is served on, else the + * plane's base for `protocol` on the default family — or the override. */ -const AUTH_FOR: Readonly< - Record Record> -> = { - "anthropic-messages": (key) => ({ - "anthropic-version": "2023-06-01", - "x-api-key": key, - }), - "google-generative-ai": (key) => ({ "x-goog-api-key": key }), - "openai-completions": (key) => ({ authorization: `Bearer ${key}` }), - "openai-responses": (key) => ({ authorization: `Bearer ${key}` }), -}; - -/** A body shaped for the protocol, so the gateway judges the MODEL, not the JSON. */ -const BODY_FOR: Readonly>> = { - "anthropic-messages": { - max_tokens: 1, - messages: [{ content: "hi", role: "user" }], - }, - // An empty `messages` array is itself a 400, which would make every - // completions probe uninformative — the gateway would reject the body before - // it ever looked at the model. - "openai-completions": { - max_tokens: 8, - messages: [{ content: "hi", role: "user" }], - stream: false, - }, - "google-generative-ai": { - // Measured: without `role` the upstream refuses with "Please use a valid - // role: user, model" — the gateway forwards it, which is itself proof the - // path and the x-goog-api-key convention are accepted. - contents: [{ parts: [{ text: "hi" }], role: "user" }], - generationConfig: { maxOutputTokens: 8 }, - }, - "openai-responses": { input: "hi", max_output_tokens: 16 }, -}; - -/** Headers the plugin injects, so a probe looks like the traffic we actually send. */ -const PLUGIN_HEADERS: Readonly> = { - "content-type": "application/json", - "user-agent": OPENCODE_PATCH_USER_AGENT, - "x-opencode-client": "cli", - "x-opencode-project": "global", - "x-opencode-session": "ses_e2e0000000000abcdefghij", -}; - -interface Probe { - readonly status: number; - readonly summary: string; -} - -/** Ask one endpoint about one model, optionally with a credential. */ -const probe = async ( +const urlFor = ( + plane: "go" | "zen", protocol: string, - model: string, - key?: string, - base: string = ZEN_BASE -): Promise => { - const template = PATH_FOR[protocol]; + model: string +): string => { + const override = BASE_OVERRIDE[plane]; + const template = + override === undefined ? PLUGIN_PATH_FOR[protocol] : PATH_FOR[protocol]; if (template === undefined) { throw new Error(`no live path is known for protocol ${protocol}`); } const path = template.replace("{model}", model); - // A protocol whose path carries the model does not also want it in the body: - // the Gemini endpoints reject unknown fields rather than ignoring them. - const payload = template.includes("{model}") - ? BODY_FOR[protocol] - : { ...BODY_FOR[protocol], model }; - const auth = - key === undefined || key.length === 0 - ? {} - : (AUTH_FOR[protocol]?.(key) ?? {}); - let response: Response; - try { - response = await fetch(`${base}${path}`, { - body: JSON.stringify(payload), - headers: { ...PLUGIN_HEADERS, ...auth }, - method: "POST", - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - } catch (error) { - // Status 0 means "the gateway did not answer" — neither agreement nor - // refusal. Measured while writing this: under rate limiting the gateway - // answers 429 at first and then holds the connection open without a - // response until the client gives up, so a timeout here is the gateway being - // busy rather than this plugin being wrong. Reporting it as a status is what - // lets the case SKIP instead of failing on an answer nobody received. - return { - status: 0, - summary: `no answer: ${ - error instanceof Error ? error.message : String(error) - }`, - }; + if (override !== undefined) { + return `${override}${path}`; } - const body = await response.text(); - return { - status: response.status, - summary: `${response.status} ${body.slice(0, 200)}`, - }; + const served = servedOn(plane, model); + const base = + served?.api === protocol + ? served.baseUrl + : baseUrlFor( + PLANE_ROUTE[plane].route, + protocol as never, + undefined, + "modern", + PLANE_ROUTE[plane].family + ); + return `${base}${path}`; }; +/** Ask one endpoint about one model, optionally with a credential. */ +const probe = async ( + protocol: string, + model: string, + key?: string, + plane: "go" | "zen" = "zen" +): Promise => + sendProbe({ + key, + model, + protocol, + timeoutMs: TIMEOUT_MS, + url: urlFor(plane, protocol, model), + }); + const otherProtocols = (expected: string) => Object.keys(PATH_FOR).filter((protocol) => protocol !== expected); -/** Whether the gateway refused on ENTITLEMENT rather than on anything we control. */ -/** - * Whether the gateway's answer makes routing UNOBSERVABLE, so the case skips. - * - * Two shapes qualify, and both are the gateway speaking about the ACCOUNT - * rather than about the route: - * - * - `403 Model access is disabled` — the account has no paid access, so every - * endpoint answers alike and nothing about routing can be seen. - * - `429` — the account is rate limited. Measured while writing this: the - * gateway answers `429 Upstream request failed` on the first calls, and then - * HOLDS THE CONNECTION open without a response for a minute, which is what - * turned a routing case into a timeout rather than a skip. Every endpoint - * would answer alike here too, so the same reasoning applies. - * - * A skip is the honest answer in both: it says the routing was not verified, - * which is not the same as saying it was verified and passed. - */ /** * Whether the gateway is refusing the FREE TIER rather than the route. * @@ -265,78 +226,48 @@ const otherProtocols = (expected: string) => const freeTierClosed = (result: Probe): boolean => result.summary.includes("FreeTierError"); -/** - * Whether the gateway answered that it no longer serves the MODEL at all. - * - * `400 Upstream request failed: Model is unavailable` and `410` are the gateway - * speaking about the model rather than the route: the catalog can still list an - * id the upstream has withdrawn (measured 2026-10-26 on `nemotron-3-ultra-free`, - * answered identically on two runs a minute apart). Such a candidate is skipped - * like any other the gateway left nothing to assert about, never asserted on. - */ -const modelGone = (result: Probe): boolean => - result.status === 410 || - (result.status === 400 && result.summary.includes("Model is unavailable")); - -const accessDisabled = (result: Probe): boolean => - result.status === 402 || - result.summary.includes("Insufficient account funds") || - (result.status === 403 && - result.summary.includes("Model access is disabled")) || - result.status === 429 || - // No answer at all is the third shape of "cannot tell": the gateway is busy, - // so nothing about the route was observed either way. - result.status === 0; +/** The route id each plane is reached by, and its family map. */ +const PLANE_ROUTE = { + go: { family: OPENCODE_FAMILY_API.go, route: "opencode-go" }, + zen: { family: OPENCODE_FAMILY_API.zen, route: "opencode" }, +} as const; /** - * The protocol the model is SERVED ON, per the shipped catalog on the Zen plane. - * - * `undefined` only when the model is not in the Zen catalog at all. A model with - * no redirect is NOT "unrouted" — it stays on the route the user configured, and - * that route speaks completions, so the default is a real answer rather than a - * missing one. Conflating those two made this helper report `undefined` for - * every default model, which is most of the catalog. + * The protocol and base a model is SERVED ON, ON ITS OWN PLANE: the model + * object the plane adapter builds (`planeModelFor`, D16), read rather than + * restated here, so this cannot drift away from what the plugin will do. * - * Read through the real exported table rather than restated here, so this cannot - * drift away from what the plugin will do. The plane is explicit because the two - * planes declare different SDKs for the same id — that difference is the whole - * subject of one of the cases below. + * `undefined` only when the model is not in that plane's catalog at all. A + * model with no SDK of its own is served on completions, which is a real + * answer rather than a missing one. The plane is a parameter because the same + * id can exist on both planes and be served on a different shape in each. */ -/** The route id each plane is reached by. */ -const ROUTE_FOR_PLANE = { go: "opencode-go", zen: "opencode" } as const; - -/** - * The protocol a model is served on, ON ITS OWN PLANE. - * - * The plane is a parameter rather than a constant because the same model id can - * exist on both planes and be served on a different shape in each — which is the - * whole reason the route is chosen from (plane, SDK) and not from the SDK. - */ -const servedProtocolForOn = ( +const servedOn = ( plane: "go" | "zen", model: string -): string | undefined => { +): { api: string; baseUrl: string } | undefined => { const spec = findModelSpecOn(plane, model); if (spec === undefined) { return undefined; } - return internalRouteFor(ROUTE_FOR_PLANE[plane], model, spec.provider_npm) === - undefined - ? DEFAULT_PROTOCOL - : PROTOCOL_FOR_SDK[spec.provider_npm ?? ""]; + return planeModelFor({ + defaults: { contextWindow: 262_144, input: ["text"], maxTokens: 32_768 }, + endpoint: "modern", + entry: { id: model }, + familyApi: PLANE_ROUTE[plane].family, + route: PLANE_ROUTE[plane].route, + spec, + }).model; }; +const servedProtocolForOn = ( + plane: "go" | "zen", + model: string +): string | undefined => servedOn(plane, model)?.api; + const servedProtocolFor = (model: string): string | undefined => servedProtocolForOn("zen", model); -/** Whether the layer redirects this model off the configured route at all. */ -const redirectsOff = (model: string): boolean => - internalRouteFor( - "opencode", - model, - findModelSpecOn("zen", model)?.provider_npm - ) !== undefined; - describe.skipIf(!LIVE)("live protocol routing", () => { /** * Pick a model from a candidate list by ASKING the gateway. @@ -355,7 +286,7 @@ describe.skipIf(!LIVE)("live protocol routing", () => { candidates: readonly string[], protocol: string, key?: string, - base: string = ZEN_BASE, + plane: "go" | "zen" = "zen", // A case that asserts one particular answer narrows the pick to candidates // that give it, so an otherwise observable answer of another kind (an // unmetered 200 where the case asserts the free-tier gate) moves the walk on @@ -363,7 +294,7 @@ describe.skipIf(!LIVE)("live protocol routing", () => { accept: (answer: Probe) => boolean = () => true ): Promise => { for (const model of candidates) { - const answer = await probe(protocol, model, key, base); + const answer = await probe(protocol, model, key, plane); if ( !accessDisabled(answer) && !modelGone(answer) && @@ -392,7 +323,7 @@ describe.skipIf(!LIVE)("live protocol routing", () => { candidates, "openai-completions", undefined, - ZEN_BASE, + "zen", freeTierClosed ); if (model === undefined) { @@ -400,9 +331,10 @@ describe.skipIf(!LIVE)("live protocol routing", () => { return; } const expected = servedProtocolFor(model); - expect(expected, `${model} no longer redirects`).toBe( - "openai-completions" - ); + expect( + expected, + `${model} is no longer served on the expected protocol` + ).toBe("openai-completions"); const right = await probe(expected ?? "", model); if (accessDisabled(right)) { @@ -472,9 +404,10 @@ describe.skipIf(!LIVE)("live protocol routing", () => { return; } const expected = servedProtocolFor(model); - expect(expected, `${model} no longer redirects`).toBe( - "openai-completions" - ); + expect( + expected, + `${model} is no longer served on the expected protocol` + ).toBe("openai-completions"); const response = await probe(expected ?? "", model); if (accessDisabled(response) || freeTierClosed(response)) { @@ -518,7 +451,10 @@ describe.skipIf(!LIVE)("live protocol routing", () => { return; } const expected = servedProtocolFor(model); - expect(expected, `${model} no longer redirects`).toBe("openai-responses"); + expect( + expected, + `${model} is no longer served on the expected protocol` + ).toBe("openai-responses"); const right = await probe(expected ?? "", model, ZEN_KEY); if (accessDisabled(right)) { @@ -591,9 +527,8 @@ describe.skipIf(!LIVE)("live protocol routing", () => { findModelSpecOn("zen", model)?.provider_npm, `${model} Zen plane` ).toBeUndefined(); - // And our routing follows the Zen plane, so no redirect happens and the - // model is served on the route the user configured. - expect(redirectsOff(model), `${model} must not redirect`).toBe(false); + // And our routing follows the Zen plane, so the model is served as + // completions there, whatever Go's row says. expect(servedProtocolFor(model)).toBe(DEFAULT_PROTOCOL); const onCompletions = await probe("openai-completions", model, ZEN_KEY); @@ -683,10 +618,11 @@ describe.skipIf(!LIVE)("live protocol routing", () => { return; } expect(servedProtocolFor(model)).toBe("google-generative-ai"); - // And the routing agrees: this is the one protocol whose dispatch lands - // on the route the plugin mounts itself. - expect(internalRouteFor("opencode", model, GOOGLE_SDK)).toBe( - GOOGLE_INTERNAL_ROUTE + // The URL probed below is the one the plane adapter sends to, Zen's own + // Google base on the default family (D13); this pins it, so a family + // change is a visible edit here rather than a silent retarget. + expect(urlFor("zen", "google-generative-ai", model)).toBe( + `https://opencode.ai/inference/google/v1beta/models/${model}:streamGenerateContent?alt=sse` ); if (ZEN_KEY === undefined || ZEN_KEY.length === 0) { context.skip(); @@ -721,12 +657,13 @@ describe.skipIf(!LIVE)("live protocol routing", () => { ); it("probes a Go model on the GO base, where its own shapes are served", async (context) => { - // The gap this closes: every case above talks to the Zen base, so the Go + // The gap this closes: every case above talks to the Zen plane, so the Go // plane's non-completions models were covered by unit tests only - and // the unit tests read the very mapping they verify. This sends a real - // request to the Go base with the Go credential, which is the only thing - // that can prove the plane is routed where it is served. The subject is - // picked live from the Go catalog's Responses-family slice. + // request to the URL the plane adapter uses for it + // (`/inference/go/openai/v1/responses`) with the Go credential, which is + // the only thing that can prove the plane is routed where it is served. + // The subject is picked live from the Go catalog's Responses-family slice. const candidates = OPENCODE_GO_CATALOG.filter( (m) => m.provider_npm === "@ai-sdk/openai" ).map((m) => m.id); @@ -734,18 +671,16 @@ describe.skipIf(!LIVE)("live protocol routing", () => { context.skip(); return; } - const model = await pickModel( - candidates, - "openai-responses", - GO_KEY, - GO_BASE - ); + const model = await pickModel(candidates, "openai-responses", GO_KEY, "go"); if (model === undefined) { context.skip(); return; } expect(servedProtocolForOn("go", model)).toBe("openai-responses"); - const parsed = await probe("openai-responses", model, GO_KEY, GO_BASE); + expect(urlFor("go", "openai-responses", model)).toBe( + "https://opencode.ai/inference/go/openai/v1/responses" + ); + const parsed = await probe("openai-responses", model, GO_KEY, "go"); if (accessDisabled(parsed)) { // The claim below is "any answer the gateway produced after reading the model // proves this path is the right one". A status 0 is no answer at all, and @@ -754,13 +689,16 @@ describe.skipIf(!LIVE)("live protocol routing", () => { context.skip(); return; } - // The claim is "this path is the right one", not "this request succeeds": - // any answer the gateway produced after reading the model proves that, and a - // 404 (no such path) or 500 (the shape does not parse) proves the opposite. + // The claim is "the plugin's path for this model is a right one", not + // "this request succeeds": any answer the gateway produced after reading + // the model proves that, and a 404 (no such path), a 500 (the shape does + // not parse) or a 400 ModelProtocolUnsupported (served, but not in this + // format) proves the opposite. // Asserting a specific code instead would make the case a bet on an // entitlement this account may or may not have today. expect(parsed.status).not.toBe(404); expect(parsed.status).not.toBe(500); + expect(parsed.summary).not.toContain("ModelProtocolUnsupported"); }); it("probes the GO plane's OTHER shape too, so both are covered", async (context) => { @@ -780,14 +718,17 @@ describe.skipIf(!LIVE)("live protocol routing", () => { candidates, "anthropic-messages", GO_KEY, - GO_BASE + "go" ); if (model === undefined) { context.skip(); return; } expect(servedProtocolForOn("go", model)).toBe("anthropic-messages"); - const parsed = await probe("anthropic-messages", model, GO_KEY, GO_BASE); + expect(urlFor("go", "anthropic-messages", model)).toBe( + "https://opencode.ai/inference/go/anthropic/v1/messages" + ); + const parsed = await probe("anthropic-messages", model, GO_KEY, "go"); if (accessDisabled(parsed)) { // Same as its sibling: a non-answer satisfies `not.toBe(404)` without proving // anything, so the case would pass on a gateway that never replied. @@ -796,6 +737,40 @@ describe.skipIf(!LIVE)("live protocol routing", () => { } expect(parsed.status).not.toBe(404); expect(parsed.status).not.toBe(500); + expect(parsed.summary).not.toContain("ModelProtocolUnsupported"); + }); + + it("probes a Go Gemini model on the Go Google base the plane adapter sends it to", async (context) => { + // pi 1.1.0's own Go provider wires no Google protocol (F10); the plane + // adapter does, on `/inference/go/google/v1beta` under every family. This + // is the keyed half of that claim: the gateway reads the model there. + const candidates = OPENCODE_GO_CATALOG.filter( + (m) => m.provider_npm === GOOGLE_SDK + ).map((m) => m.id); + if (GO_KEY === undefined || GO_KEY.length === 0) { + context.skip(); + return; + } + const model = await pickModel( + candidates, + "google-generative-ai", + GO_KEY, + "go" + ); + if (model === undefined) { + context.skip(); + return; + } + expect(servedProtocolForOn("go", model)).toBe("google-generative-ai"); + expect(servedOn("go", model)?.baseUrl).toBe(GO_GOOGLE_BASE_URL); + const parsed = await probe("google-generative-ai", model, GO_KEY, "go"); + if (accessDisabled(parsed)) { + context.skip(); + return; + } + expect(parsed.status).not.toBe(404); + expect(parsed.status).not.toBe(500); + expect(parsed.summary).not.toContain("ModelProtocolUnsupported"); }); it("keeps every sampled model servable, so the cases above are not vacuous", () => { @@ -825,6 +800,12 @@ describe.skipIf(!LIVE)("live protocol routing", () => { (m) => m.provider_npm === "@ai-sdk/anthropic" ).map((m) => m.id), ], + [ + "go google", + OPENCODE_GO_CATALOG.filter((m) => m.provider_npm === GOOGLE_SDK).map( + (m) => m.id + ), + ], ]; for (const [label, ids] of slices) { expect( @@ -898,3 +879,244 @@ describe.skipIf(!LIVE)("live protocol routing", () => { FANOUT_BUDGET_MS ); }); + +/** An answer about the account, not the wire: no paid access, no funds, rate limited. */ +const ADAPTER_ENTITLEMENT = + /\b40[23]\b|\b429\b|access is disabled|Insufficient account funds|FreeTierError/iu; + +/** The plane routes the adapter block sends on, with the catalog and key each reads. */ +const ADAPTER_PLANES = [ + { + catalog: OPENCODE_ZEN_CATALOG, + key: ZEN_KEY, + ref: "OPENCODE_API_KEY", + route: "opencode", + }, + { + catalog: OPENCODE_GO_CATALOG, + key: GO_KEY, + ref: "OPENCODE_GO_API_KEY", + route: "opencode-go", + }, +] as const; + +/** + * The most paid candidates any one protocol declares in the adapter block. + * Bounded so a plane of withdrawn models costs a few turns, not the catalog. + */ +const ADAPTER_CANDIDATES = 3; + +/** + * Wall-clock allowance for the adapter block: up to {@link ADAPTER_CANDIDATES} + * turns per protocol, each possibly followed by a raw probe that confirms a + * withdrawal, at {@link TIMEOUT_MS} apiece. + */ +const ADAPTER_BUDGET_MS = 4 * ADAPTER_CANDIDATES * 2 * TIMEOUT_MS + 60_000; + +/** A paid row's protocol on its plane, or `undefined` for a row it cannot serve. */ +const paidProtocolOf = ( + spec: (typeof OPENCODE_ZEN_CATALOG)[number] +): string | undefined => { + if (spec.is_free === true || !isServableSdk(spec.provider_npm)) { + return undefined; + } + return spec.provider_npm === undefined + ? DEFAULT_PROTOCOL + : (PROTOCOL_FOR_SDK[spec.provider_npm] ?? DEFAULT_PROTOCOL); +}; + +/** One adapter plane, as {@link ADAPTER_PLANES} lists it. */ +type AdapterPlane = (typeof ADAPTER_PLANES)[number]; + +/** + * One turn through the plane adapter, read the way the walk reads it: a + * completion, an answer about the account, a confirmed withdrawal, or a + * refusal that fails the case (a 400 INVALID_ARGUMENT thinking refusal among + * them, the live D16 failure). + */ +const adapterVerdict = async ( + tier: Awaited>, + plane: AdapterPlane, + protocol: string, + id: string, + extra: Readonly> = {} +): Promise => { + const drained = await drainStream(tier.llm, plane.route, id, { + signal: AbortSignal.timeout(TIMEOUT_MS), + ...extra, + }); + if ( + drained.error === undefined && + drained.finish?.kind !== undefined && + drained.finish.kind !== "error" + ) { + return { kind: "ok" }; + } + const message = drained.failureMessage ?? drained.error ?? ""; + if (ADAPTER_ENTITLEMENT.test(message)) { + return { kind: "entitlement" }; + } + if (isModelGoneMessage(message)) { + // The gateway answers "Model is unavailable" for an id sent where it is + // not served, too (upstream-findings §3.8). Ask the endpoint the plugin + // should use, by hand, before reading it as the vendor's state. + const planeName = plane.route === "opencode" ? "zen" : "go"; + const raw = await probe(protocol, id, plane.key, planeName); + if (modelGone(raw)) { + return { kind: "gone" }; + } + if (accessDisabled(raw)) { + return { kind: "entitlement" }; + } + return { + detail: `the turn says the model is gone (${message}), a raw probe of the same id answered ${raw.summary}`, + kind: "refused", + }; + } + return { detail: message, kind: "refused" }; +}; + +/** Google's discrete thinking levels, lowest first. */ +const GOOGLE_LEVELS = ["minimal", "low", "medium", "high"] as const; + +/** The highest level a catalog spec's first effort list offers. */ +const highestLevelOf = ( + spec: (typeof OPENCODE_ZEN_CATALOG)[number] +): string | undefined => { + const values = + spec.reasoning_options?.find( + (option) => option.type === "effort" && (option.values?.length ?? 0) > 0 + )?.values ?? []; + return GOOGLE_LEVELS.findLast((level) => values.includes(level)); +}; + +describe.skipIf(!LIVE)( + "live protocol routing · through the plugin's plane adapter", + () => { + it.for(ADAPTER_PLANES)( + "$route: one paid model per protocol completes a turn through the plane adapter", + { timeout: ADAPTER_BUDGET_MS }, + async (plane, context) => { + context.skip( + plane.key === undefined || plane.key.length === 0, + "no key for this plane" + ); + // An override names somewhere the plugin does not send. + context.skip( + BASE_OVERRIDE[plane.route === "opencode" ? "zen" : "go"] !== + undefined, + "a base override is set: the adapter sends where the plugin sends" + ); + const groups = new Map( + [ + ...candidatesByProtocol( + plane.catalog, + paidProtocolOf, + ADAPTER_CANDIDATES + ), + ].map(([protocol, specs]) => [protocol, specs.map((s) => s.id)]) + ); + // Anti-vacuity: each plane carries completions, Responses, Messages + // and Google models. + expect([...groups.keys()].toSorted()).toEqual([ + "anthropic-messages", + "google-generative-ai", + "openai-completions", + "openai-responses", + ]); + const tier = await mountTierA({ + credentials: fakeCredentials({ + refs: { [plane.ref]: plane.key ?? "" }, + }), + plugin: {}, + providers: { + [plane.route]: { + apiKeyEnv: plane.ref, + models: [...groups.values()].flat().map((id) => ({ id })), + }, + }, + }); + try { + await waitForPlaneOwner(tier.llm, [plane.route]); + const walk = await walkPerProtocol(groups, (protocol, id) => + adapterVerdict(tier, plane, protocol, id) + ); + expect(walk.refused).toEqual([]); + // A protocol no candidate reached was not tested: a skip that names + // it, never a pass on the others. + context.skip( + walk.unreached.length > 0, + `unreached (every candidate refused on entitlement or withdrawn): ${walk.unreached.join(", ")}` + ); + } finally { + await tier.dispose(); + } + } + ); + + it.for(ADAPTER_PLANES)( + "$route: a Google model the catalog describes by level completes a turn at its highest offered level (D16)", + { timeout: ADAPTER_BUDGET_MS }, + async (plane, context) => { + context.skip( + plane.key === undefined || plane.key.length === 0, + "no key for this plane" + ); + context.skip( + BASE_OVERRIDE[plane.route === "opencode" ? "zen" : "go"] !== + undefined, + "a base override is set: the adapter sends where the plugin sends" + ); + // A level model pi's own pattern misses, so the turn exercises the + // plugin's Google wrapper; once pi learns every such id the case + // names that and skips, rather than re-testing pi's own path. + const candidates = plane.catalog + .filter( + (spec) => + paidProtocolOf(spec) === "google-generative-ai" && + usesCatalogThinkingLevels(spec) && + !usesGoogleThinkingLevel({ id: spec.id } as never) + ) + .slice(0, ADAPTER_CANDIDATES); + context.skip( + candidates.length === 0, + "no paid Google model the catalog describes by level that pi's own pattern misses" + ); + const tier = await mountTierA({ + credentials: fakeCredentials({ + refs: { [plane.ref]: plane.key ?? "" }, + }), + plugin: {}, + providers: { + [plane.route]: { + apiKeyEnv: plane.ref, + models: candidates.map((spec) => ({ id: spec.id })), + }, + }, + }); + try { + await waitForPlaneOwner(tier.llm, [plane.route]); + const levelOf = new Map( + candidates.map((spec) => [spec.id, highestLevelOf(spec)]) + ); + const walk = await walkPerProtocol( + new Map([ + ["google-generative-ai", candidates.map((spec) => spec.id)], + ]), + (protocol, id) => + adapterVerdict(tier, plane, protocol, id, { + reasoningEffort: levelOf.get(id), + }) + ); + expect(walk.refused).toEqual([]); + context.skip( + walk.unreached.length > 0, + `unreached (every candidate refused on entitlement or withdrawn): ${walk.unreached.join(", ")}` + ); + } finally { + await tier.dispose(); + } + } + ); + } +); diff --git a/test/fetch-patch.test.ts b/test/fetch-patch.test.ts index 4fb042d8..190d0f65 100644 --- a/test/fetch-patch.test.ts +++ b/test/fetch-patch.test.ts @@ -1171,12 +1171,12 @@ describe("patchFetch", () => { ); }); - it("resolves a credential for the Zen free model that gets redirected to /responses", async () => { + 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 the stream hook redirects it to `opencode-responses` - - // a route the plugin mounts itself, which therefore carries pi's - // `Bearer unused` placeholder and depends on THIS injection to authenticate. + // `@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). // // A key captured from the user's own plane route, on the endpoint family the // catalog now names. @@ -1193,7 +1193,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" }), - // The sentinel the mounted route declares; a placeholder must be + // pi's sentinel for a route with no key; a placeholder must be // replaced, never forwarded. headers: { authorization: "Bearer unused" }, method: "POST", diff --git a/test/go-discovery.test.ts b/test/go-discovery.test.ts index 16b4e0af..4026620b 100644 --- a/test/go-discovery.test.ts +++ b/test/go-discovery.test.ts @@ -6,10 +6,9 @@ * plane, and which of the four credential sources (composition literal, * captured route key, credentials/env reference, any captured key) the row's * `keySource` policy lets win. Every other consumer — the meter's - * `resolveGoApiKey`, `patchFetch`'s Authorization fallback, the mounted - * Responses routes' `inheritedCredentialRef` — inherits its answer from here, - * so a wrong answer sends the wrong key to the wrong endpoint and fails as an - * outage the user cannot act on. + * `resolveGoApiKey` and `patchFetch`'s Authorization fallback — inherits its + * answer from here, so a wrong answer sends the wrong key to the wrong + * endpoint and fails as an outage the user cannot act on. * * 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 @@ -39,7 +38,6 @@ import { type DiscoveredGoConfig, KEY_SOURCE_POLICIES, clearCapturedApiKeys, - declaredProviderModels, discoverGoConfig, effectiveGoKeyRef, getCapturedApiKey, @@ -1239,78 +1237,3 @@ describe("go-discovery: the installation's own store, and where it sits", () => ).resolves.toBeUndefined(); }); }); - -describe("go-discovery · the declaration in the loader", () => { - // **A GENERATOR, because that is what the host returns.** The first version of - // this reader guarded with `Array.isArray` and rejected a working loader — - // measured as the routes still not mounting after the fix that was supposed to - // mount them. - const loaderWith = (providers: unknown) => ({ - loader: { - *entries() { - yield { options: { config: { providers } } }; - }, - }, - }); - - it("reads the declared ids and names for one route", () => { - const ctx = loaderWith({ - opencode: { models: [{ id: "gpt-5.4", name: "GPT-5.4" }, { id: "" }] }, - "opencode-go": { models: [{ id: "deepseek-v4.1-flash" }] }, - }); - expect(declaredProviderModels(ctx, "opencode")).toEqual([ - { id: "gpt-5.4", name: "GPT-5.4" }, - ]); - // A route the loader does not describe is UNDEFINED, not empty: "cannot - // read" and "declares nothing" must stay distinguishable. - expect(declaredProviderModels(ctx, "nvidia")).toBeUndefined(); - }); - - it("answers an empty list for a row that declares no models", () => { - expect( - declaredProviderModels(loaderWith({ opencode: {} }), "opencode") - ).toEqual([]); - }); - - it("survives a context it cannot read at all", () => { - // Not an object, no loader anywhere, and a `get` that throws: each is - // "cannot read", never an exception out of a boot path. - expect(declaredProviderModels(undefined, "opencode")).toBeUndefined(); - expect(declaredProviderModels({}, "opencode")).toBeUndefined(); - expect( - declaredProviderModels( - { - get: () => { - throw new Error("no loader"); - }, - }, - "opencode" - ) - ).toBeUndefined(); - // A `get` that answers nothing falls through to the property, which here - // does not exist either. - expect( - declaredProviderModels({ get: () => {} }, "opencode") - ).toBeUndefined(); - }); - - it("survives entries that are not an array or not shaped like rows", () => { - expect( - declaredProviderModels({ loader: { entries: () => "nope" } }, "opencode") - ).toBeUndefined(); - expect( - declaredProviderModels( - { - loader: { - entries: () => [ - null, - { options: null }, - { options: { config: {} } }, - ], - }, - }, - "opencode" - ) - ).toBeUndefined(); - }); -}); diff --git a/test/go-google-prepare.test.ts b/test/go-google-prepare.test.ts deleted file mode 100644 index 79b08781..00000000 --- a/test/go-google-prepare.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { decoratePlaneAdapters, resolveConfig } from "./support/src.ts"; - -const model = "gemini-4-argon"; -const route = "opencode-go-google"; - -describe("Go Google preparation", () => { - it("resolves on the bridge before the source adapter and restores the public identity", async () => { - const originalResolve = vi.fn(() => - Promise.reject(new Error("unknown model")) - ); - const originalPrepare = vi.fn(() => - Promise.reject(new Error("unknown model")) - ); - const source = { - resolveModel: originalResolve, - prepareCall: originalPrepare, - }; - const bridge = { - resolveModel: vi.fn(async () => ({ provider: route, id: model })), - prepareCall: vi.fn(async () => ({ - model: { provider: route, id: model }, - token: "prepared", - })), - }; - const adapters = new Map([ - ["opencode-go", { adapter: source }], - [route, { adapter: bridge }], - ]); - const stop = decoratePlaneAdapters( - { adapters } as Parameters[0], - resolveConfig() - ); - try { - const { signal } = new AbortController(); - expect( - await Reflect.apply(source.resolveModel, source, [ - "opencode-go", - model, - signal, - ]) - ).toEqual({ provider: "opencode-go", id: model }); - expect( - await Reflect.apply(source.prepareCall, source, [ - "opencode-go", - model, - signal, - ]) - ).toEqual({ - model: { provider: "opencode-go", id: model }, - token: "prepared", - }); - expect(bridge.resolveModel).toHaveBeenCalledWith(route, model, signal); - expect(bridge.prepareCall).toHaveBeenCalledWith(route, model, signal); - expect(originalResolve).not.toHaveBeenCalled(); - expect(originalPrepare).not.toHaveBeenCalled(); - } finally { - stop?.(); - } - expect(source.resolveModel).toBe(originalResolve); - expect(source.prepareCall).toBe(originalPrepare); - }); - - it.each([undefined, null])( - "preserves an absent bridge result: %s", - async (result) => { - const source = { resolveModel: async () => ({ id: model }) }; - const bridge = { resolveModel: async () => result }; - const adapters = new Map }>([ - ["opencode-go", { adapter: source }], - [route, { adapter: bridge }], - ]); - const stop = decoratePlaneAdapters( - { adapters } as Parameters[0], - resolveConfig() - ); - try { - expect( - await Reflect.apply(source.resolveModel, source, [ - "opencode-go", - model, - ]) - ).toBe(result); - } finally { - stop?.(); - } - } - ); - - it("honors a protocol override instead of redirecting to Google", async () => { - const original = vi.fn(async () => ({ - provider: "opencode-go", - id: model, - })); - const source = { resolveModel: original }; - const bridge = { resolveModel: vi.fn() }; - const adapters = new Map }>([ - ["opencode-go", { adapter: source }], - [route, { adapter: bridge }], - ]); - const stop = decoratePlaneAdapters( - { adapters } as Parameters[0], - resolveConfig({ - modelRoutes: { - [model]: { api: "openai-completions" }, - }, - }) - ); - try { - await Reflect.apply(source.resolveModel, source, ["opencode-go", model]); - expect(original).toHaveBeenCalledOnce(); - expect(bridge.resolveModel).not.toHaveBeenCalled(); - } finally { - stop?.(); - } - }); -}); diff --git a/test/go-google.test.ts b/test/go-google.test.ts deleted file mode 100644 index 16c033ae..00000000 --- a/test/go-google.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { AsyncLocalStorage } from "node:async_hooks"; - -import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; -import { describe, expect, it } from "vitest"; - -import { testPi } from "./google-runtime-fixture.ts"; -import { - type ActiveTurnState, - GO_GOOGLE_BASE_URL, - GO_GOOGLE_INTERNAL_ROUTE, - OPENCODE_GO_CATALOG, - createStreamHook, - getSessionUsage, - hideResponsesRoute, - internalRouteFor, - patchFetch, - registerGoogleBridge, - resolveConfig, -} from "./support/src.ts"; - -interface WirePart { - functionCall?: { name: string }; - functionResponse?: { name: string }; - thoughtSignature?: string; -} -interface Chunk { - type: string; - text: string; - block: { type: string; id: string; arguments: string }; - reason: { kind: string }; - replayState: { blocks: unknown[] }; -} - -const createCredentialsSeam = () => { - const refs = new Map(); - const credentials = { - set: (name: string, value: string) => - Promise.resolve(refs.set(name, value)), - resolve: (name: string) => Promise.resolve({ value: refs.get(name) }), - }; - return { - credentials, - ctx: { - get: (name: string) => (name === "credentials" ? credentials : undefined), - }, - }; -}; - -const model = "gemini-4-argon"; -const specs = OPENCODE_GO_CATALOG.filter((spec) => spec.id === model); - -describe("Go Gemini native bridge", () => { - it("keeps the picker limited to declared models and hides the bridge", async () => { - const existing = [{ provider: "opencode-go", id: "kimi-k3", name: "Kimi" }]; - const ctx = { - llm: { - listProviders: () => [ - { id: "opencode-go", name: "Go" }, - { id: GO_GOOGLE_INTERNAL_ROUTE, name: "Google" }, - ], - listModels: async (_provider: string) => existing, - }, - }; - const stop = hideResponsesRoute(ctx, resolveConfig()); - try { - expect(await ctx.llm.listModels("opencode-go")).toEqual(existing); - expect(await ctx.llm.listModels(GO_GOOGLE_INTERNAL_ROUTE)).toEqual([]); - expect(existing).toHaveLength(1); - } finally { - stop?.(); - } - }); - it("preserves Go authentication, session affinity and signed tool replay across two turns", async () => { - expect(specs).toHaveLength(1); - expect(internalRouteFor("opencode-go", model)).toBe( - GO_GOOGLE_INTERNAL_ROUTE - ); - const seam = createCredentialsSeam(); - await seam.credentials.set("CUSTOM_GO_KEY", "go-fixture-key"); - await seam.credentials.set("OPENCODE_API_KEY", "zen-must-not-be-used"); - let adapter: PiAiAdapter | undefined; - registerGoogleBridge({ - pi: testPi(), - ctx: seam.ctx, - host: { PiAiAdapter }, - specs, - plane: "opencode-go", - apiKeyRef: "CUSTOM_GO_KEY", - llm: { - registerAdapter(routes, instance) { - expect(routes).toEqual([GO_GOOGLE_INTERNAL_ROUTE]); - adapter = instance as PiAiAdapter; - }, - }, - }); - const als = new AsyncLocalStorage(); - const config = resolveConfig({ - userAgent: "dsh-desktop-go-gemini/1.0", - injectOriginHeaders: false, - }); - const ctx = { - llm: { - listProviders: () => [ - { id: GO_GOOGLE_INTERNAL_ROUTE, name: "Go Google" }, - ], - stream: (options: unknown): unknown => - hook(options, () => adapter!.stream(options as never)), - }, - }; - const hook = createStreamHook(ctx, config, als); - const stopHiding = hideResponsesRoute(ctx, config); - const requests: { - url: string; - headers: Headers; - body: { contents: { parts: WirePart[] }[] }; - }[] = []; - const real = globalThis.fetch; - globalThis.fetch = patchFetch( - (async (input, init) => { - requests.push({ - url: input instanceof Request ? input.url : String(input), - headers: new Headers(init?.headers), - body: JSON.parse(typeof init?.body === "string" ? init.body : "{}"), - }); - const parts = - requests.length === 1 - ? [ - { - functionCall: { name: "lookup", args: { item: "sample" } }, - thoughtSignature: "c2lnbmVkLXRvb2wtZml4dHVyZQ==", - }, - ] - : [{ text: "The result is 42." }]; - return new Response( - `data: ${JSON.stringify({ - candidates: [ - { content: { role: "model", parts }, finishReason: "STOP" }, - ], - usageMetadata: { - promptTokenCount: 10, - candidatesTokenCount: 3, - totalTokenCount: 13, - }, - })}\n\n`, - { headers: { "content-type": "text/event-stream" } } - ); - }) as typeof fetch, - als, - config - ); - const messages: unknown[] = [ - { role: "user", content: [{ type: "text", text: "Look up sample." }] }, - ]; - const run = async () => { - const options = { - provider: "opencode-go", - model, - sessionId: "go-google-session-fixture", - messages, - tools: [ - { - name: "lookup", - description: "Read a sample value", - parameters: { - type: "object", - properties: { item: { type: "string" } }, - required: ["item"], - }, - }, - ], - }; - const chunks: Chunk[] = []; - const stream = hook(options, () => - adapter!.stream(options as never) - ) as AsyncIterable; - for await (const chunk of stream) chunks.push(chunk as Chunk); - return chunks; - }; - try { - const first = await run(); - expect( - getSessionUsage("go-google-session-fixture")?.inputTokens - ).toBeGreaterThan(0); - const finish = first.find((chunk) => chunk.type === "finish")!; - expect(finish.reason.kind).toBe("tool-calls"); - const blocks = first - .filter((chunk) => chunk.type === "block-end") - .map((chunk) => chunk.block); - const call = blocks.find((block) => block.type === "tool-call")!; - expect(JSON.parse(call.arguments)).toEqual({ item: "sample" }); - expect(finish.replayState.blocks).toContainEqual({ - type: "tool-call", - thoughtSignature: "c2lnbmVkLXRvb2wtZml4dHVyZQ==", - }); - messages.push( - { - role: "assistant", - content: blocks, - source: { - provider: "opencode-go", - model, - replayState: finish.replayState, - }, - }, - { - role: "tool", - toolCallId: call.id, - content: [{ type: "text", text: "42" }], - } - ); - const second = await run(); - expect(second.find((chunk) => chunk.type === "finish")!.reason.kind).toBe( - "stop" - ); - expect( - second.some( - (chunk) => chunk.type === "text-delta" && chunk.text.includes("42") - ) - ).toBe(true); - expect(requests).toHaveLength(2); - for (const request of requests) { - expect(request.url).toBe( - `${GO_GOOGLE_BASE_URL}/models/${model}:streamGenerateContent?alt=sse` - ); - expect(request.headers.get("x-goog-api-key")).toBe("go-fixture-key"); - expect(request.headers.get("user-agent")).toBe( - "dsh-desktop-go-gemini/1.0" - ); - expect(request.headers.get("x-opencode-session")).toMatch(/^ses_/); - } - expect(requests[0]!.headers.get("x-opencode-session")).toBe( - requests[1]!.headers.get("x-opencode-session") - ); - const replayParts = requests[1]!.body.contents.flatMap( - (content) => content.parts - ); - expect( - replayParts.some( - (part) => - part.functionCall && - part.thoughtSignature === "c2lnbmVkLXRvb2wtZml4dHVyZQ==" - ) - ).toBe(true); - expect( - replayParts.some((part) => part.functionResponse?.name === "lookup") - ).toBe(true); - } finally { - stopHiding?.(); - globalThis.fetch = real; - } - }); - - it("does not fall through to Zen when the configured Go key is missing", async () => { - const seam = createCredentialsSeam(); - await seam.credentials.set("OPENCODE_API_KEY", "zen-must-not-be-used"); - let adapter: PiAiAdapter | undefined; - registerGoogleBridge({ - pi: testPi(), - ctx: seam.ctx, - host: { PiAiAdapter }, - specs, - plane: "opencode-go", - apiKeyRef: "MISSING_GO_FIXTURE_KEY", - llm: { - registerAdapter(_routes, instance) { - adapter = instance as PiAiAdapter; - }, - }, - }); - let requests = 0; - const real = globalThis.fetch; - globalThis.fetch = (() => { - requests += 1; - throw new Error("network must not be used"); - }) as typeof fetch; - try { - await expect(async () => { - for await (const _ of adapter!.stream({ - provider: GO_GOOGLE_INTERNAL_ROUTE, - model, - messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], - } as never)) { - /* consume */ - } - }).rejects.toThrow(/MISSING_CREDENTIAL/); - expect(requests).toBe(0); - } finally { - globalThis.fetch = real; - } - }); -}); diff --git a/test/google-bridge.test.ts b/test/google-bridge.test.ts deleted file mode 100644 index 4d79e977..00000000 --- a/test/google-bridge.test.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { readFileSync } from "node:fs"; -/** - * `google-bridge.ts` — the route this plugin registers for itself, verified - * against the real `PiAiAdapter` class and the real Google protocol module - * rather than a stand-in for either. - * - * The bridge exists because every supported path to these models was measured - * and closed: the wrapper's hand-declared table refuses the protocol, a - * catalog route may only take the catalog provider's own name, and the - * registration handle that would hand a route back is never seen by a plugin - * that loads after the wrapper. What this file proves is that the objects the - * bridge builds by hand are the ones the real consumer accepts. - */ - -import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; -import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; -import { - builtinProviders, - getBuiltinModels as piModelsFor, -} from "@earendil-works/pi-ai/providers/all"; -import { describe, expect, it } from "vitest"; - -import { - GOOGLE_INTERNAL_ROUTE, - GOOGLE_SDK, - type GoogleBridgeInputs, - OPENCODE_ZEN_CATALOG, - type PiRuntime, - ambientAuthContext, - emptyCredentialStore, - googleProviderFor, - loadHostPi, - registerGoogleBridge, - resolveGatewayKey, -} from "./support/src.ts"; - -/** The Gemini models of the Zen plane, straight from the generated catalog. */ -const geminiSpecs = OPENCODE_ZEN_CATALOG.filter( - (spec) => spec.provider_npm === GOOGLE_SDK -); - -/** One model the provider serves, as this file reads it back. */ -interface ModelLike extends Record { - api: string; - baseUrl: string; - id: string; - provider: string; -} - -/** The provider surface this file exercises. */ -interface ProviderLike { - getModels: () => ModelLike[]; - stream: ( - model: ModelLike, - context: unknown, - options?: unknown - ) => AsyncIterable; - streamSimple: ( - model: ModelLike, - context: unknown, - options?: unknown - ) => AsyncIterable; -} - -/** - * The pi runtime the bridge consumes, built from the root pi the suite imports. - * - * A deployment gets the harness's own pi (`loadHostPi` finds it through - * `dsh-llm-pi-ai`). Under the pin (`test/support/host-versions.ts`) the root pi - * and the wrapper's are one copy, and these tests exercise the contract, not a - * version. `host-versions.test.ts` (its first two cases) proves the loader - * finds the host's copy in the real tree and that it is this same module. - */ -const testPi = (): PiRuntime => ({ - getBuiltinModels: (plane) => - piModelsFor(plane as Parameters[0]), - googleGenerativeAIApi, - version: "test", -}); - -/** The bridge's provider, through the one cast the test needs. */ -const providerAs = (specs: typeof geminiSpecs): ProviderLike => - googleProviderFor(testPi(), specs) as unknown as ProviderLike; - -/** The `llm` the bridge registers on: it records what the service received. */ -const recordingLlm = (): { - llm: GoogleBridgeInputs["llm"]; - registered: { adapter: unknown; routes: readonly string[] }[]; -} => { - const registered: { adapter: unknown; routes: readonly string[] }[] = []; - return { - llm: { - registerAdapter: (routes, adapter) => { - registered.push({ adapter, routes }); - return () => {}; - }, - }, - registered, - }; -}; - -/** The bridge's standard inputs, against the real adapter class. */ -const bridgeInputs = ( - llm: GoogleBridgeInputs["llm"], - apiKeyRef = "OPENCODE_API_KEY" -): GoogleBridgeInputs => ({ - apiKeyRef, - ctx: {}, - host: { PiAiAdapter }, - llm, - pi: testPi(), - specs: geminiSpecs, -}); - -describe("google-bridge · the provider it builds", () => { - it("carries every Gemini model of the Zen plane with the gateway base", () => { - const models = providerAs(geminiSpecs).getModels(); - expect(models.length).toBe(geminiSpecs.length); - for (const model of models) { - // **The Google family has its own base.** Measured from the vendor's - // `GET /console/api/config`: `…/inference/google/v1beta`, not the - // `…/inference/openai/v1` the plane's openai protocols use. Deriving it by - // string surgery on one plane URL is what sent these models to the wrong - // endpoint. - expect(model.api).toBe("google-generative-ai"); - expect(model.baseUrl).toBe("https://opencode.ai/inference/google/v1beta"); - // The provider field names the route, the way the wrapper's own model - // resolution sets it — not the plane the model was catalogued on. - expect(model.provider).toBe(GOOGLE_INTERNAL_ROUTE); - } - }); - - it("keeps every field the installed catalog knows about the model", () => { - // The regression this guards: building the model object field by field - // drops everything pi knows that this module does not model — the - // compatibility switches, the reasoning flag, the thinking-level map, the - // input limits — and each of those changes the request silently. The - // wrapper's own materialization spreads the catalog entry for exactly this - // reason; the claim here is that the bridge does too. - const pi = builtinProviders().find( - (provider) => provider.id === "opencode" - ); - expect(pi).toBeDefined(); - const installed = pi?.getModels() ?? []; - const ours = providerAs(geminiSpecs).getModels(); - // The fields this module deliberately sets; everything else must come - // through untouched from the installed entry. - const oursToSet = new Set([ - "api", - "baseUrl", - "contextWindow", - "cost", - "id", - "input", - "maxTokens", - "name", - "provider", - ]); - let compared = 0; - for (const model of ours) { - const entry = installed.find((candidate) => candidate.id === model.id); - if (entry === undefined) { - // pi does not ship this id, so there was nothing to inherit. - continue; - } - for (const [key, value] of Object.entries(entry)) { - if (oursToSet.has(key)) { - continue; - } - expect(model[key], `${model.id} lost ${key}`).toEqual(value); - compared += 1; - } - } - // A vacuous pass would compare nothing; say so instead. - expect(compared).toBeGreaterThan(0); - }); - - it("sends each model to the gateway with the resolved key", async () => { - const provider = providerAs(geminiSpecs); - const seen: { headers: Record; url: string }[] = []; - const real = globalThis.fetch; - globalThis.fetch = (async ( - input: unknown, - init?: { headers?: unknown } - ) => { - let raw: Record = {}; - const candidate = init?.headers; - if (candidate instanceof Headers) { - raw = {}; - for (const [key, value] of candidate.entries()) { - raw[key] = value; - } - } else if (typeof candidate === "object" && candidate !== null) { - raw = candidate as Record; - } - const headers: Record = {}; - for (const [key, value] of Object.entries(raw)) { - headers[key.toLowerCase()] = value; - } - seen.push({ - headers, - url: typeof input === "string" ? input : String(input), - }); - throw new Error("captured"); - }) as typeof globalThis.fetch; - try { - for (const model of provider.getModels()) { - seen.length = 0; - try { - const stream = provider.stream( - model, - { messages: [{ role: "user", content: "hi" }] }, - { apiKey: "oc_sk_resolved" } - ); - for await (const _ of stream) { - // The capture refuses the call; nothing arrives. - } - } catch { - // Expected: the capture refuses the request. - } - expect(seen.length).toBe(1); - expect(seen[0]?.url).toBe( - `https://opencode.ai/inference/google/v1beta/models/${model.id}:streamGenerateContent?alt=sse` - ); - // This protocol's credential header, measured against the gateway: - // the request-level key rides x-goog-api-key, not Authorization. - expect(seen[0]?.headers["x-goog-api-key"]).toBe("oc_sk_resolved"); - } - // `streamSimple` is pi's normalization path into the same wire - // implementation, so it must reach the same endpoint. - seen.length = 0; - const [first] = provider.getModels(); - if (first === undefined) { - throw new Error("the provider served no models to probe"); - } - try { - const simple = provider.streamSimple( - first as ModelLike, - { messages: [{ role: "user", content: "hi" }] }, - { apiKey: "oc_sk_resolved" } - ); - for await (const _ of simple) { - // The capture refuses the call; nothing arrives. - } - } catch { - // Expected: the capture refuses the request. - } - expect(seen.length).toBe(1); - expect(seen[0]?.url).toBe( - `https://opencode.ai/inference/google/v1beta/models/${first.id}:streamGenerateContent?alt=sse` - ); - } finally { - globalThis.fetch = real; - } - }); -}); - -describe("google-bridge · the adapter it registers", () => { - it("registers the route through the real PiAiAdapter class", () => { - const { llm, registered } = recordingLlm(); - const handle = registerGoogleBridge(bridgeInputs(llm)); - expect(handle).toBeDefined(); - expect(registered.length).toBe(1); - expect(registered[0]?.routes).toEqual([GOOGLE_INTERNAL_ROUTE]); - }); - - it("answers the service's questions from the profile the real class consumed", () => { - const { llm, registered } = recordingLlm(); - registerGoogleBridge(bridgeInputs(llm)); - const adapter = registered[0]?.adapter as { - providerInfo: (provider: string) => { id: string; name: string }; - providerRetryPolicy: (provider: string) => { - maxRetries: number; - mode: string; - }; - }; - expect(adapter).toBeDefined(); - // A profile the real class rejects would make these throw, not lie. - expect(adapter.providerInfo(GOOGLE_INTERNAL_ROUTE)).toEqual({ - id: GOOGLE_INTERNAL_ROUTE, - name: "OpenCode (Google)", - }); - // The defaults were read from the harness's own retry table: five - // retries is its number, not ours. - const policy = adapter.providerRetryPolicy(GOOGLE_INTERNAL_ROUTE); - expect(policy.mode).toBe("normal"); - expect(policy.maxRetries).toBe(5); - }); - - it("declines to mount where no loaded wrapper handed it an adapter", () => { - const { llm, registered } = recordingLlm(); - const handle = registerGoogleBridge({ - ...bridgeInputs(llm), - host: undefined, - }); - expect(handle).toBeUndefined(); - expect(registered.length).toBe(0); - }); -}); - -describe("google-bridge · the auth injectables it hands the collection", () => { - it("answers every credential-store read as absent", async () => { - // The route's key always arrives per request, so this store is never the - // path a stream call reads — but it must still satisfy the collection's - // shape honestly rather than pretending to hold something. - const store = emptyCredentialStore(); - await expect(store.read("opencode-google")).resolves.toBeUndefined(); - await expect(store.list()).resolves.toEqual([]); - await expect(store.delete("opencode-google")).resolves.toBeUndefined(); - }); - - it("passes a modify through with no current credential", async () => { - // pi's write path sees the current credential; an empty store's is - // absent, and the callback's answer is the store's answer. - const store = emptyCredentialStore(); - const written = await store.modify("opencode-google", (current) => - Promise.resolve({ current }) - ); - expect(written).toEqual({ current: undefined }); - }); - - it("reads the ambient context from the process environment", async () => { - process.env.BRIDGE_AMBIENT_KEY = "ambient-value"; - try { - const auth = ambientAuthContext(); - await expect(auth.env("BRIDGE_AMBIENT_KEY")).resolves.toBe( - "ambient-value" - ); - await expect(auth.fileExists("/nope")).resolves.toBe(false); - } finally { - process.env.BRIDGE_AMBIENT_KEY = ""; - } - }); -}); - -describe("google-bridge · the request the adapter actually sends", () => { - /** - * Everything else in this file exercises a piece. This one runs the whole - * path the user would: the real `PiAiAdapter`, our profile, pi's Google - * implementation, and the credential the plugin resolves — with only the - * network intercepted. - * - * It is the test the earlier probe could not stand in for. The probe proved - * the ENDPOINT answers; this proves the REQUEST the bridge builds is one the - * endpoint accepts — the Gemini body needs a `role` on each content, and an - * earlier hand-written probe was rejected for omitting exactly that. - */ - it("streams through the real adapter to the gateway, credential and all", async () => { - process.env.BRIDGE_STREAM_KEY = "oc_sk_stream_resolved"; - const { llm, registered } = recordingLlm(); - try { - registerGoogleBridge(bridgeInputs(llm, "BRIDGE_STREAM_KEY")); - const adapter = registered[0]?.adapter as { - stream: (options: unknown) => AsyncIterable; - }; - expect(adapter).toBeDefined(); - - const sent: { - body: string; - headers: Record; - url: string; - }[] = []; - let failure: unknown; - const real = globalThis.fetch; - globalThis.fetch = (async ( - input: unknown, - init?: { body?: unknown; headers?: unknown } - ) => { - const headers: Record = {}; - const candidate = init?.headers; - if (candidate instanceof Headers) { - for (const [key, value] of candidate.entries()) { - headers[key] = value; - } - } else if (typeof candidate === "object" && candidate !== null) { - for (const [key, value] of Object.entries( - candidate as Record - )) { - headers[key] = value; - } - } - const raw = init?.body; - sent.push({ - body: typeof raw === "string" ? raw : "", - headers, - url: typeof input === "string" ? input : String(input), - }); - throw new Error("captured"); - }) as typeof globalThis.fetch; - try { - for await (const _ of adapter.stream({ - // The host's content is a block array, not a bare string: the - // adapter scans it for images before it resolves the key. - messages: [{ content: [{ text: "hi", type: "text" }], role: "user" }], - model: "gemini-3.8-flash", - provider: GOOGLE_INTERNAL_ROUTE, - })) { - // The capture refuses the call; nothing arrives. - } - } catch (error) { - // The capture refuses the request, which surfaces here. Keeping the - // error means a failure BEFORE the fetch says why, instead of looking - // like a capture that never fired. - failure = error; - } finally { - globalThis.fetch = real; - } - - expect(sent.length, `no request reached fetch: ${String(failure)}`).toBe( - 1 - ); - expect(sent[0]?.url).toBe( - "https://opencode.ai/inference/google/v1beta/models/gemini-3.8-flash:streamGenerateContent?alt=sse" - ); - // The credential the plugin resolved, on the header this protocol reads. - expect(sent[0]?.headers["x-goog-api-key"]).toBe("oc_sk_stream_resolved"); - // And a body the gateway has been measured to accept: the role is the - // part a hand-written probe got wrong. - const body = JSON.parse(sent[0]?.body ?? "{}") as { - contents?: { role?: string }[]; - }; - expect(body.contents?.[0]?.role).toBe("user"); - } finally { - process.env.BRIDGE_STREAM_KEY = ""; - } - }); -}); - -describe("google-bridge · the key it resolves", () => { - it("reads a set reference from the environment", async () => { - process.env.BRIDGE_TEST_KEY = "oc_sk_from_env"; - try { - await expect( - resolveGatewayKey({}, "BRIDGE_TEST_KEY", "opencode-google") - ).resolves.toBe("oc_sk_from_env"); - } finally { - process.env.BRIDGE_TEST_KEY = ""; - } - }); - - it("fails loud when a named reference resolves to nothing", async () => { - delete process.env.BRIDGE_TEST_ABSENT; - // The loud miss is the point: pi would treat `undefined` as "resolve - // ambient auth instead", billing whatever key it finds first. - await expect( - resolveGatewayKey({}, "BRIDGE_TEST_ABSENT", "opencode-google") - ).rejects.toThrow(/MISSING_CREDENTIAL/); - }); - - it("answers undefined only when no reference was named", async () => { - await expect( - resolveGatewayKey({}, undefined, "opencode-google") - ).resolves.toBeUndefined(); - }); -}); - -describe("google-bridge · the pi copy it loads", () => { - it("declares no pi of its own at runtime, and loads the host's", async () => { - // The rule this replaced a version pin with: the plugin must NOT ship a pi. - // The wrapper (`dsh-llm-pi-ai`) already depends on `@earendil-works/pi-ai` - // with a range (`^1.1.0`): the harness's own lockfile pins that to 1.1.0 - // plus its patch, a registry install of the published host floats it to the - // newest 1.x, and this repo's dev install pins 1.1.0. A second copy here - // would put two versions in one tree and turn every harness bump into a - // version-skew bug. - const ours = JSON.parse(readFileSync("package.json", "utf8")) as { - dependencies?: Record; - devDependencies?: Record; - }; - expect( - ours.dependencies?.["@earendil-works/pi-ai"], - "pi is a runtime dependency again: a harness bump would skew against it" - ).toBeUndefined(); - // It stays a devDependency for this repo's own tests and development. - expect(ours.devDependencies?.["@earendil-works/pi-ai"]).toBeDefined(); - - // And the loader actually reaches the HOST's copy, not a vendored one. - const pi = await loadHostPi(); - expect( - pi, - "the harness's pi was not reachable from dsh-llm-pi-ai" - ).toBeDefined(); - expect(typeof pi?.googleGenerativeAIApi).toBe("function"); - expect(typeof pi?.getBuiltinModels).toBe("function"); - expect(pi?.version).toMatch(/^\d+\.\d+\.\d+/u); - }); - - it("builds the provider from whichever copy the host installed", () => { - // A version change may alter what the protocol EMITS; it may not change - // whether the objects interoperate, because pi reads fields and never checks - // identity. The provider is built from the runtime it is given. - const models = providerAs(geminiSpecs).getModels(); - expect(models.length).toBe(geminiSpecs.length); - }); -}); - -describe("google-bridge · the credential reference", () => { - it("fails loud rather than falling back to ambient auth", async () => { - // The security rule: handing pi `undefined` for a NAMED reference would let - // it pick up an unrelated ambient key and bill another tenant. A miss must - // throw, not return undefined. - await expect( - resolveGatewayKey({}, "BRIDGE_ABSENT_REF_XYZ", "opencode-google") - ).rejects.toThrow(/MISSING_CREDENTIAL/u); - }); - - it("answers nothing for a reference that is not a name", async () => { - // A route whose `apiKeyEnv` is absent/empty has no reference to resolve — - // that is the adapter's cue to try the stored login, not a credential miss. - for (const ref of [undefined, null, "", 42, {}]) { - await expect( - resolveGatewayKey({}, ref, "opencode-google") - ).resolves.toBeUndefined(); - } - }); - - it("prefers the credentials service over the environment", async () => { - process.env.BRIDGE_BOTH_SOURCES = "from-env"; - try { - const ctx = { - get: (name: string) => - name === "credentials" - ? { - resolve: async () => ({ value: "from-service" }), - } - : undefined, - }; - await expect( - resolveGatewayKey(ctx, "BRIDGE_BOTH_SOURCES", "opencode-google") - ).resolves.toBe("from-service"); - } finally { - delete process.env.BRIDGE_BOTH_SOURCES; - } - }); -}); diff --git a/test/google-levels.test.ts b/test/google-levels.test.ts new file mode 100644 index 00000000..68379ea9 --- /dev/null +++ b/test/google-levels.test.ts @@ -0,0 +1,535 @@ +import { + stream as piStream, + streamSimple as piStreamSimple, +} from "@earendil-works/pi-ai/api/google-generative-ai"; +/** + * The Google protocol's level wrapper, one branch at a time + * (`src/host/google-levels.ts`, D16). + * + * pi 1.1.0 sends Google's discrete `thinkingLevel` only to ids its own pattern + * recognizes, and a token `thinkingBudget` to every other id — which a model + * that thinks in levels refuses (measured live on Go gemini-4-argon). The + * wrapper serves the ids the CATALOG describes by level the way pi serves a + * level id, and hands every other call to pi untouched. + * + * The pieces are pi's own public exports from this repository's install; in + * the branch cases only `stream` is a recorder, so each reads the options pi's + * request builder would have been handed. One differential case runs the real + * pieces end to end against pi's own `streamSimple` as the oracle, so a pi + * bump that changes the level path fails here. The wire itself is pinned by + * the mock suite (`test/mock/plane-dispatch.mock.ts`). + * + * @module test/google-levels.test + */ +import { + resolveGoogleThinkingLevel, + toGoogleSdkThinkingLevel, + toGoogleThinkingLevel, + usesGoogleThinkingLevel, +} from "@earendil-works/pi-ai/api/google-shared"; +import { lazyStream } from "@earendil-works/pi-ai/api/lazy"; +import { buildBaseOptions } from "@earendil-works/pi-ai/api/simple-options"; +import { clampThinkingLevel } from "@earendil-works/pi-ai/models"; +import { describe, expect, it, vi } from "vitest"; + +import { + type GoogleLevelPieces, + type GoogleLevelSource, + withCatalogThinkingLevels, +} from "./support/src.ts"; + +/** A level model the catalog describes and pi's pattern misses. */ +const ARGON = { + api: "google-generative-ai", + baseUrl: "https://opencode.ai/inference/go/google/v1beta", + contextWindow: 1_048_576, + id: "gemini-4-argon", + maxTokens: 65_536, + provider: "opencode-go", + reasoning: true, + thinkingLevelMap: { + high: "high", + low: "low", + max: null, + medium: "medium", + minimal: null, + off: null, + xhigh: null, + }, +} as const; + +/** + * The level path's fixture: ARGON under an id no pi pattern can name, so the + * mechanism cases exercise the wrapper on any pi. Which REAL ids pi's pattern + * misses is one characterization row below, not a premise of every case. + */ +const LEVEL = { ...ARGON, id: "level-only-model" } as const; + +const CONTEXT = { + messages: [{ content: "hi", role: "user", timestamp: 0 }], +}; + +/** pi's pieces, with `stream` recording what it is handed. */ +const piecesWith = ( + stream: GoogleLevelPieces["stream"] +): GoogleLevelPieces => ({ + buildBaseOptions: + buildBaseOptions as unknown as GoogleLevelPieces["buildBaseOptions"], + clampThinkingLevel: + clampThinkingLevel as unknown as GoogleLevelPieces["clampThinkingLevel"], + resolveGoogleThinkingLevel: + resolveGoogleThinkingLevel as unknown as GoogleLevelPieces["resolveGoogleThinkingLevel"], + stream, + toGoogleSdkThinkingLevel: + toGoogleSdkThinkingLevel as unknown as GoogleLevelPieces["toGoogleSdkThinkingLevel"], + toGoogleThinkingLevel: + toGoogleThinkingLevel as unknown as GoogleLevelPieces["toGoogleThinkingLevel"], + usesGoogleThinkingLevel: + usesGoogleThinkingLevel as unknown as GoogleLevelPieces["usesGoogleThinkingLevel"], +}); + +/** pi's Google API as a recorder: which entry point answered, with what. */ +const fakeApi = () => { + const calls: { via: string; args: unknown[] }[] = []; + const api = { + marker: "kept", + stream: (...args: unknown[]) => { + calls.push({ args, via: "stream" }); + return "pi-stream"; + }, + streamSimple: (...args: unknown[]) => { + calls.push({ args, via: "streamSimple" }); + return "pi-streamSimple"; + }, + }; + return { api, calls }; +}; + +/** A source whose `lazyStream` runs the setup and answers its promise. */ +const sourceOf = ( + pieces?: GoogleLevelPieces +): GoogleLevelSource & { setups: number } => { + const source = { + lazyStream: (_model: unknown, setup: () => Promise) => { + source.setups += 1; + return setup(); + }, + load: () => Promise.resolve(pieces), + setups: 0, + }; + return source; +}; + +type Simple = (model: unknown, context: unknown, options?: unknown) => unknown; + +const simpleOf = (wrapped: unknown): Simple => + (wrapped as { streamSimple: Simple }).streamSimple; + +describe("google-levels · what stays pi's", () => { + it("answers the api itself without a source, an empty set, or no streamSimple", () => { + const { api } = fakeApi(); + const source = sourceOf(piecesWith(vi.fn())); + expect(withCatalogThinkingLevels(api, new Set([ARGON.id]))).toBe(api); + expect(withCatalogThinkingLevels(api, new Set(), source)).toBe(api); + const noSimple = { stream: api.stream }; + expect( + withCatalogThinkingLevels(noSimple, new Set([ARGON.id]), source) + ).toBe(noSimple); + expect( + withCatalogThinkingLevels(undefined, new Set([ARGON.id]), source) + ).toBeUndefined(); + }); + + it("keeps every other member, and hands any other id to pi's streamSimple with the same arguments, synchronously", () => { + const { api, calls } = fakeApi(); + const source = sourceOf(piecesWith(vi.fn())); + const wrapped = withCatalogThinkingLevels( + api, + new Set([ARGON.id]), + source + ) as typeof api; + expect(wrapped).not.toBe(api); + expect(wrapped.stream).toBe(api.stream); + expect(wrapped.marker).toBe("kept"); + const other = { ...ARGON, id: "gemini-2.5-pro" }; + const options = { apiKey: "k", reasoning: "high" }; + expect(simpleOf(wrapped)(other, CONTEXT, options)).toBe("pi-streamSimple"); + // A model that is not a record, or does not reason, is pi's too. + expect(simpleOf(wrapped)(undefined, CONTEXT, options)).toBe( + "pi-streamSimple" + ); + expect( + simpleOf(wrapped)({ ...ARGON, reasoning: false }, CONTEXT, options) + ).toBe("pi-streamSimple"); + expect(calls.map((call) => call.args[0])).toEqual([ + other, + undefined, + { ...ARGON, reasoning: false }, + ]); + expect(calls[0]?.args).toEqual([other, CONTEXT, options]); + expect(calls[0]?.args[2]).toBe(options); + expect(source.setups).toBe(0); + }); + + it("hands a listed id pi's own pattern already serves by level back to pi", async () => { + const stream = vi.fn(); + const { api, calls } = fakeApi(); + const flash = { ...ARGON, id: "gemini-3-flash" }; + const source = sourceOf(piecesWith(stream)); + const wrapped = withCatalogThinkingLevels(api, new Set([flash.id]), source); + const options = { apiKey: "k" }; + expect(await simpleOf(wrapped)(flash, CONTEXT, options)).toBe( + "pi-streamSimple" + ); + expect(calls).toEqual([ + { args: [flash, CONTEXT, options], via: "streamSimple" }, + ]); + expect(stream).not.toHaveBeenCalled(); + }); + + it("hands a listed id to pi when the host's pi lacks the level pieces", async () => { + const { api, calls } = fakeApi(); + const wrapped = withCatalogThinkingLevels( + api, + new Set([ARGON.id]), + sourceOf() + ); + expect(await simpleOf(wrapped)(ARGON, CONTEXT, { apiKey: "k" })).toBe( + "pi-streamSimple" + ); + expect(calls.map((call) => call.via)).toEqual(["streamSimple"]); + }); +}); + +describe("google-levels · characterization of the installed pi", () => { + it("pi 1.1.0's pattern misses gemini-4-argon (the live case), and the fixture id on any pi", () => { + // The row a pi bump flips: once pi's own pattern names argon, the wrapper + // hands it back to pi and serves no current catalog id. The mechanism + // cases below run on `level-only-model` and stay green either way. + expect(usesGoogleThinkingLevel({ id: ARGON.id } as never)).toBe(false); + expect(usesGoogleThinkingLevel({ id: LEVEL.id } as never)).toBe(false); + expect(usesGoogleThinkingLevel({ id: "gemini-3-flash" } as never)).toBe( + true + ); + }); +}); + +describe("google-levels · a catalog level model, the way pi serves a level model", () => { + /** One call through the wrapper; answers the options `stream` was handed. */ + const optionsFor = async ( + model: Record, + options: Record | undefined + ): Promise> => { + const stream = vi.fn(() => "level-stream"); + const { api, calls } = fakeApi(); + const wrapped = withCatalogThinkingLevels( + api, + new Set([String(model.id)]), + sourceOf(piecesWith(stream as unknown as GoogleLevelPieces["stream"])) + ); + expect(await simpleOf(wrapped)(model, CONTEXT, options)).toBe( + "level-stream" + ); + expect(calls).toEqual([]); + expect(stream).toHaveBeenCalledTimes(1); + const [streamed, context, handed] = stream.mock.calls[0] as unknown as [ + unknown, + unknown, + Record, + ]; + expect(streamed).toBe(model); + expect(context).toBe(CONTEXT); + return handed; + }; + + it("a chosen level is sent as that level, clamped to what the model offers, with pi's base options", async () => { + const { signal } = new AbortController(); + const high = await optionsFor(LEVEL, { + apiKey: "k", + maxTokens: 1000, + reasoning: "high", + signal, + toolChoice: "auto", + }); + expect(high).toMatchObject({ + apiKey: "k", + maxTokens: 1000, + signal, + thinking: { enabled: true, level: "HIGH" }, + toolChoice: "auto", + }); + expect(high).toEqual({ + ...buildBaseOptions( + LEVEL as never, + CONTEXT as never, + { apiKey: "k", maxTokens: 1000, reasoning: "high", signal } as never, + "k" + ), + thinking: { enabled: true, level: "HIGH" }, + toolChoice: "auto", + }); + const xhigh = await optionsFor(LEVEL, { apiKey: "k", reasoning: "xhigh" }); + expect(xhigh.thinking).toEqual({ enabled: true, level: "HIGH" }); + const minimal = await optionsFor(LEVEL, { + apiKey: "k", + reasoning: "minimal", + }); + expect(minimal.thinking).toEqual({ enabled: true, level: "LOW" }); + }); + + it("no level chosen sends the level pi falls back to, as pi's disabled config for a level model, before the caller's onPayload", async () => { + // An Off the model does not offer clamps to its lowest level, as pi's + // own level path clamps it. + const off = await optionsFor(LEVEL, { apiKey: "k", reasoning: "off" }); + expect(off.thinking).toEqual({ enabled: true, level: "LOW" }); + for (const reasoning of [undefined, ""]) { + const seen: unknown[] = []; + const handed = await optionsFor(LEVEL, { + apiKey: "k", + onPayload: (params: unknown) => { + seen.push(params); + }, + ...(reasoning === undefined ? {} : { reasoning }), + }); + expect(handed.thinking).toEqual({ enabled: false }); + const onPayload = handed.onPayload as ( + params: unknown, + model: unknown + ) => Promise; + // pi's builder answers `thinkingBudget: 0` for the disabled path of an + // id its pattern misses; the payload hook replaces it before anything + // else reads the request. + const params = { + config: { temperature: 1, thinkingConfig: { thinkingBudget: 0 } }, + contents: [], + model: LEVEL.id, + }; + const sent = await onPayload(params, LEVEL); + expect(sent).toEqual({ + config: { temperature: 1, thinkingConfig: { thinkingLevel: "LOW" } }, + contents: [], + model: LEVEL.id, + }); + expect(seen).toEqual([sent]); + expect(params.config.thinkingConfig).toEqual({ thinkingBudget: 0 }); + } + }); + + it("the caller's onPayload still has the last word, and a hook without a caller answers the rewrite", async () => { + const replaced = { replaced: true }; + const handed = await optionsFor(LEVEL, { + apiKey: "k", + onPayload: () => Promise.resolve(replaced), + }); + const hook = handed.onPayload as ( + params: unknown, + model: unknown + ) => Promise; + expect(await hook({ config: {} }, LEVEL)).toBe(replaced); + const bareOptions = await optionsFor(LEVEL, { apiKey: "k" }); + const bare = bareOptions.onPayload as ( + params: unknown, + model: unknown + ) => Promise; + expect(await bare({ config: { thinkingConfig: {} } }, LEVEL)).toEqual({ + config: { thinkingConfig: { thinkingLevel: "LOW" } }, + }); + // A payload that is not the shape pi builds passes through untouched. + expect(await bare("opaque", LEVEL)).toBe("opaque"); + expect(await bare({ config: "opaque" }, LEVEL)).toEqual({ + config: "opaque", + }); + }); + + it("the lowest offered level is the fallback, and a declared Off is pi's disabled config (thinkingBudget: 0 for an id pi's pattern misses)", async () => { + const minimal = { + ...LEVEL, + thinkingLevelMap: { ...LEVEL.thinkingLevelMap, minimal: "minimal" }, + }; + const minimalOptions = await optionsFor(minimal, { apiKey: "k" }); + const hook = minimalOptions.onPayload as ( + params: unknown, + model: unknown + ) => Promise; + expect(await hook({ config: {} }, minimal)).toEqual({ + config: { thinkingConfig: { thinkingLevel: "MINIMAL" } }, + }); + // A declared Off (no `off: null`): pi's own disable, and the caller's + // hook untouched — exactly pi's level path. pi's request builder then + // sends `thinkingBudget: 0` for an id its pattern misses, which a model + // that cannot turn thinking off refuses: a profile that declares Off for + // such a model is a profile error (the mock suite pins the wire). + const { off: _off, ...offered } = LEVEL.thinkingLevelMap; + const withOff = { ...LEVEL, thinkingLevelMap: offered }; + const caller = (): undefined => undefined; + const handed = await optionsFor(withOff, { + apiKey: "k", + onPayload: caller, + }); + expect(handed.thinking).toEqual({ enabled: false }); + expect(handed.onPayload).toBe(caller); + }); + + it("refuses a call without a key the way pi's streamSimple does", async () => { + const stream = vi.fn(); + const { api } = fakeApi(); + const wrapped = withCatalogThinkingLevels( + api, + new Set([LEVEL.id]), + sourceOf(piecesWith(stream)) + ); + await expect(simpleOf(wrapped)(LEVEL, CONTEXT, {})).rejects.toThrow( + "No API key for provider: opencode-go" + ); + await expect(simpleOf(wrapped)(LEVEL, CONTEXT)).rejects.toThrow( + "No API key for provider: opencode-go" + ); + expect(stream).not.toHaveBeenCalled(); + }); +}); + +describe("google-levels · the request matches pi's own streamSimple for a level id", () => { + /** + * The oracle is pi, not the wrapper's recipe: pi's real `streamSimple` on a + * level id its pattern names, against the wrapper — real pieces, real + * `stream`, real `lazyStream` — on the same model under an id the pattern + * misses. Each pair differs ONLY by id, and neither id of a pair splits + * pi's other id-keyed rules (the Gemini major version decides strict tool + * sampling and tool-call ids): `gemini-flash-latest` and `level-only-model` + * carry no version, `gemini-3-flash` and `gemini-4-argon` are both 3+. + * + * This compares what reaches the request body — the params pi's caller + * hook is handed — and nothing pi's `stream` uses without writing it there + * (headers, retries, timeouts). The hook throws, so no request is sent. + */ + const PAIRS = [ + { piId: "gemini-flash-latest", wrappedId: LEVEL.id }, + { piId: "gemini-3-flash", wrappedId: ARGON.id }, + ] as const; + + const DIFF_CONTEXT = { + messages: [ + { + content: "Be brief.", + role: "system", + timestamp: 0, + toolsAdded: [ + { + constrainedSampling: { type: "json_schema" }, + description: "Read a file", + name: "read", + parameters: { + properties: { path: { type: "string" } }, + required: ["path"], + type: "object", + }, + }, + ], + }, + { content: "hi", role: "user", timestamp: 0 }, + ], + }; + + type Run = ( + model: unknown, + context: unknown, + options: Record + ) => unknown; + + /** The params one call hands the caller's hook, minus the id and signal. */ + const requestOf = async ( + run: Run, + model: Record, + reasoning: string | undefined + ): Promise => { + const seen: unknown[] = []; + const events = run(model, DIFF_CONTEXT, { + apiKey: "k", + maxTokens: 1000, + onPayload: (params: unknown) => { + seen.push(params); + throw new Error("captured before the wire"); + }, + signal: new AbortController().signal, + temperature: 0.5, + toolChoice: "auto", + ...(reasoning === undefined ? {} : { reasoning }), + }) as AsyncIterable<{ type: string }>; + const types: string[] = []; + for await (const event of events) { + types.push(event.type); + } + expect(types).toEqual(["error"]); + expect(seen).toHaveLength(1); + const { + config, + model: id, + ...rest + } = seen[0] as { + config: Record; + model: unknown; + }; + expect(id).toBe(model.id); + const { abortSignal, ...kept } = config; + expect(abortSignal).toBeInstanceOf(AbortSignal); + return { ...rest, config: kept }; + }; + + const wrapperRun = (model: Record): Run => { + const wrapped = withCatalogThinkingLevels( + { streamSimple: piStreamSimple }, + new Set([String(model.id)]), + { + lazyStream: lazyStream as unknown as GoogleLevelSource["lazyStream"], + load: () => + Promise.resolve( + piecesWith(piStream as unknown as GoogleLevelPieces["stream"]) + ), + } + ); + return simpleOf(wrapped) as Run; + }; + + const { off: _off, ...withOffMap } = LEVEL.thinkingLevelMap; + const MODELS = [ + { name: "levels only", thinkingLevelMap: LEVEL.thinkingLevelMap }, + { name: "a declared Off", thinkingLevelMap: withOffMap }, + ] as const; + + for (const { piId, wrappedId } of PAIRS) { + for (const { name, thinkingLevelMap } of MODELS) { + for (const reasoning of ["high", "minimal", "off", undefined]) { + it(`${wrappedId} as ${piId}, ${name}, reasoning ${String(reasoning)}`, async () => { + const base = { ...LEVEL, input: ["text"], thinkingLevelMap }; + const piModel = { ...base, id: piId }; + const wrappedModel = { ...base, id: wrappedId }; + // The premise of the pair: pi serves one id by level, not the other. + expect(usesGoogleThinkingLevel(piModel as never)).toBe(true); + expect(usesGoogleThinkingLevel(wrappedModel as never)).toBe(false); + const fromPi = await requestOf( + piStreamSimple as unknown as Run, + piModel, + reasoning + ); + const fromWrapper = await requestOf( + wrapperRun(wrappedModel), + wrappedModel, + reasoning + ); + expect(fromWrapper).toEqual(fromPi); + // And the comparison has teeth: the request carries a thinking + // config, and pi's own path on the wrapped id would differ. + expect(fromPi).toHaveProperty("config.thinkingConfig"); + const unwrapped = await requestOf( + piStreamSimple as unknown as Run, + wrappedModel, + reasoning + ); + if (name === "levels only") { + expect(unwrapped).not.toEqual(fromPi); + } + }); + } + } + } +}); diff --git a/test/google-replay.test.ts b/test/google-replay.test.ts deleted file mode 100644 index e89f99b9..00000000 --- a/test/google-replay.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - googleHistoryFor, - googleReplayFor, - googleReplayStream, -} from "./support/src.ts"; - -describe("Google replay route boundary", () => { - it("leaves error chunks JSON-serializable and does not invent missing replay", async () => { - const failed = { - type: "finish", - reason: { kind: "error", failure: { code: "AUTH", message: "test" } }, - }; - async function* stream() { - yield failed; - } - const output = []; - for await (const chunk of googleReplayStream( - stream(), - "internal", - "public" - )) - output.push(chunk); - // oxlint-disable-next-line unicorn/prefer-structured-clone -- exercise the host's JSON transport, not cloning - expect(output).toEqual(JSON.parse(JSON.stringify(output))); - expect(output[0]).toBe(failed); - }); - it("does not rewrite foreign or incomplete replay envelopes", () => { - for (const state of [ - undefined, - {}, - { response: {} }, - { response: { kind: "other" } }, - { - response: { - kind: "pi-ai", - api: "anthropic-messages", - provider: "public", - }, - }, - { - response: { - kind: "pi-ai", - api: "google-generative-ai", - provider: "another-provider", - }, - }, - ]) { - expect(googleReplayFor(state, "public", "internal")).toBe(state); - } - const messages = [ - {}, - { role: "user", content: [] }, - { role: "assistant", source: { provider: "foreign" } }, - { role: "assistant", source: { provider: "public" } }, - ]; - expect(googleHistoryFor(messages, "public", "internal")).toEqual(messages); - expect(googleHistoryFor(undefined, "public", "internal")).toBeUndefined(); - }); -}); diff --git a/test/google-runtime-fixture.ts b/test/google-runtime-fixture.ts deleted file mode 100644 index f6380b7b..00000000 --- a/test/google-runtime-fixture.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; -import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; - -import type { PiRuntime } from "./support/src.ts"; - -export const testPi = (): PiRuntime => ({ - getBuiltinModels: (plane) => - getBuiltinModels(plane as Parameters[0]), - googleGenerativeAIApi, - version: "test", -}); diff --git a/test/host-characterization.test.ts b/test/host-characterization.test.ts index b878df58..bde427c3 100644 --- a/test/host-characterization.test.ts +++ b/test/host-characterization.test.ts @@ -1,37 +1,41 @@ /** - * TODAY's plugin on the real host, characterized (tier B, pi 1.1.0). + * The plugin on the real host, characterized (tier B, pi 1.1.0). * * The plugin is mounted as it ships — its row carries no config, so the - * shipped defaults apply: the hidden ids in `providers`, `enrichModels` on, - * `endpoint: auto` — beside pi-ai holding the characterization profile (S0 - * plus a declared catalog-unknown id on each plane) with a key stored for each - * plane. Every row asserts what was MEASURED, and is tagged so a later phase - * flips named rows (docs/improvement-plan.md, "Phases"): + * shipped defaults apply (`enrichModels` on, `endpoint: auto`) — beside pi-ai + * holding the characterization profile (S0 plus a declared catalog-unknown id + * on each plane) with a key stored for each plane. Every row asserts what was + * MEASURED, and is tagged so a later phase flips named rows + * (docs/improvement-plan.md, "Phases"): * - * - `[KNOWN_DEFECT]` is wrong today, and a phase fixes it; - * - `[INTENDED_CHANGE]` is right today, and a phase changes it on purpose; + * - `[KNOWN_DEFECT]` is wrong, and a phase fixes it; + * - `[INTENDED_CHANGE]` is right, and a phase changes it on purpose; * - `[BASELINE]` must survive every phase. * - * The per-model expectations live in `S0_TODAY` (`support/host.ts`), which - * the binary e2e reads too, so the in-process host and the published binary - * cannot drift apart unnoticed. Each model's three facets (model info, the + * P-T1b recorded these rows against the hidden-route workaround; P-R1, where + * the plugin owns the `opencode` and `opencode-go` routes, flipped the + * fourteen named cases and fourteen `S0_TODAY` facets its PR body lists, so + * every row here is now `[BASELINE]`. + * + * The per-model expectations live in `S0_TODAY` (`support/host.ts`), which the + * binary e2e reads too, so the in-process host and the published binary cannot + * drift apart unnoticed. Each model's three facets (model info, the * call-config verdict, the wire) carry their own tag there, and this file * generates one test per model and facet titled with that tag, in each apply * order (`todayRowsFor(order)` carries that order's tags), so the tag is what * a reader and a phase's diff go by. Where a measurement contradicted the * plan's expectation the row keeps the measurement and says so. * - * Every case boots its own host, so a key the fetch patch captured in one case - * cannot change another's listing. The main block boots in the published - * host's apply order (`plugin-first`: the plugin applies before pi-ai - * registers, measured on the binary); a second block boots `pi-ai-first` and - * records the rows that change with the order — the adapter decoration only - * wraps what was registered when the plugin applied. + * Every case boots its own host. The main block boots in the published host's + * apply order (`plugin-first`: the plugin applies before pi-ai registers, + * measured on the binary); a second block boots `pi-ai-first`, where the claim + * restarts pi-ai once, and shows that no facet depends on the order any more. * * @module test/host-characterization.test */ import { readFileSync } from "node:fs"; +import { readProfilePatches } from "@deepseek-ai/dsh-app-boot"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -40,10 +44,12 @@ import { APPLY_ORDERS, bootTierB, CHARACTERIZED_MODELS, + CHARACTERIZED_PROFILE, characterizedProviders, drainStream, fakeCredentials, PLANES, + piAiRestarts, piKnows, providerWithApiKey, recordingFetch, @@ -51,27 +57,29 @@ import { type RowFacet, S0_TODAY, s0Providers, - SAVE_BLOCKED_BY_ANY_UNDESCRIBED, SAVE_CANDIDATE, type TierB, todayRowsFor, type TodayRow, UNKNOWN_ID, - waitForRoutes, - ZEN_UNDESCRIBED, + waitForPlaneOwner, } from "./support/host.ts"; import { - INTERNAL_ROUTES, OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, - PI_THINKING_LEVELS, - thinkingLevelMapFor, + protocolForSdk, + 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; let wire: RecordingFetch; +let restarts: ReturnType | undefined; /** Boot the characterization profile with a key stored for each plane, the plugin as it ships. */ const bootS0 = async (order: ApplyOrder): Promise => { @@ -85,18 +93,55 @@ const bootS0 = async (order: ApplyOrder): Promise => { }, }), order, + prepare: (root) => { + restarts = piAiRestarts(root); + }, providers: characterizedProviders(), }); - // The plugin mounts its hidden routes asynchronously (mountInternalRoutes). - await waitForRoutes(tier.llm, INTERNAL_ROUTES); + // The plugin registers its plane adapter once its mount settles. + await waitForPlaneOwner(tier.llm); }; afterEach(async () => { + restarts?.dispose(); + restarts = undefined; await tier?.dispose(); tier = undefined; 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 +): Record => { + const row = readProfilePatches("dsh", host.profile).findLast( + (patch) => patch.id === "llm-pi-ai" && patch.config !== undefined + ); + if (row === undefined) { + throw new Error("no pi-ai row in the profile"); + } + return ( + row.config as { providers: Record } + ).providers; +}; + const booted = (): TierB => { if (tier === undefined) { throw new Error("no host booted"); @@ -118,14 +163,9 @@ const sentBy = async (route: string, model: string) => { }; }; -/** pi-ai's plane adapter, private methods included. */ -const planeAdapter = () => - adaptersOf(booted().llm).get("opencode")?.adapter as unknown as { - prepareCall: ( - route: string, - model: string - ) => Promise<{ baseUrl?: string; api?: string }>; - }; +/** The adapter serving a route, as the registry holds it. */ +const adapterOf = (route: string) => + adaptersOf(booted().llm).get(route)?.adapter; /** `resolveCallConfig`'s verdict: "ok", or the code it rejected with. */ const callConfigOf = async (route: string, model: string): Promise => { @@ -209,28 +249,6 @@ const appendTo = }; }; -/** A config-editor mutation dropping `ids` from a plane's models. */ -const dropFrom = - (plane: string, ids: readonly string[]) => - (current: Record): Record => { - const providers = current.providers as Record< - string, - { models?: { id: string }[] } - >; - return { - ...current, - providers: { - ...providers, - [plane]: { - ...providers[plane], - models: (providers[plane]?.models ?? []).filter( - (model) => !ids.includes(model.id) - ), - }, - }, - }; - }; - const FACETS = ["info", "callConfig", "wire"] as const; /** @@ -256,13 +274,13 @@ const providerKeys = (side: unknown): string[] => Object.keys((side as { providers?: object } | undefined)?.providers ?? {}); /** - * The `[BASELINE]` session-controller reads after a Sign-in, on today's plugin - * with only the two plane keys stored (what `bootS0` stores): the Zen plane - * answers `hasProviderApiKey`, and pi-ai's `settings.describe` value lists - * both planes. P-R1 strips the planes from pi-ai's resolved config, which on + * The `[BASELINE]` session-controller reads after a Sign-in, with only the two + * plane keys stored (what `bootS0` stores): the Zen plane answers + * `hasProviderApiKey`, and pi-ai's `settings.describe` value lists both + * planes. The claim strips the planes from pi-ai's resolved config, which on * the host alone turns this false (F20, the plugin-less contract in - * `test/host-editor-contracts.test.ts`); its D18 wrapper is what must keep - * this case green. + * `test/host-editor-contracts.test.ts`); the D18 graft is what keeps this case + * green. */ const expectSignInReadsTheZenPlane = async (): Promise => { const host = booted(); @@ -283,8 +301,17 @@ const expectSignInReadsTheZenPlane = async (): Promise => { ]); }; +/** Both planes are the plugin's adapter, one instance, and not pi-ai's. */ +const expectPlanesOwned = (): void => { + const zen = adapterOf("opencode"); + expect(zen?.constructor.name).toBe("PlaneAdapter"); + expect(adapterOf("opencode-go")).toBe(zen); + expect(adapterOf("deepseek")?.constructor.name).toBe("PiAiAdapter"); + expect(adapterOf("deepseek")).not.toBe(zen); +}; + describe( - "characterization · today's plugin on pi 1.1.0, the published host's order (tier B)", + "characterization · the plugin on pi 1.1.0, the published host's order (tier B)", { timeout: 30_000 }, () => { beforeEach(() => bootS0("plugin-first"), 30_000); @@ -318,25 +345,28 @@ describe( CHARACTERIZED_MODELS[plane].map((id) => `${plane}/${id}`) ) ); - expect(INTERNAL_ROUTES).toHaveLength(6); + // No hidden route: the only opencode routes are the two planes. + expect( + booted() + .llm.listProviders() + .map((provider) => provider.id) + .filter((id) => id.startsWith("opencode")) + .toSorted() + ).toEqual([...PLANES]); }); - it("fixture: each row's context and efforts are the bundled catalog's, so a catalog refresh that moves a fixture model fails here by name", () => { + it("fixture: each row's context and efforts are what D17 and D1 derive from the profile row and the bundled catalog, so a catalog refresh that moves a fixture model fails here by name", () => { // S0_TODAY copies context windows AND effort lists from the generated - // catalog, which the nightly refresh rewrites: decorateModelInfo makes - // the catalog authoritative for both (src/models-discovery.ts, the - // `thinkingLevelMapFor` → effortsFromLevelMap derivation, re-stated - // here from its two exported halves). A refresh that changes one of - // these ids must update S0_TODAY (and with it the binary e2e) — and must - // fail HERE, by name, not as a baseline facet that seems to regress. + // catalog, which the nightly refresh rewrites, except where the user's + // row names its own context window (D17: the row wins). The efforts are + // `reasoningFor` over the catalog spec, on the protocol its SDK names + // (D1, D16). A refresh that changes one of these ids must update + // S0_TODAY (and with it the binary e2e) — and must fail HERE, by name, + // not as a baseline facet that seems to regress. // - // Two rows' `info` facets are KNOWN_DEFECT today and still copy the - // catalog (Zen gpt-5.3-codex-spark's context, Go minimax-m3's efforts); - // the plan moves them OFF the catalog on purpose (D17: the user's row - // wins; D1: a toggle model offers Off/On). The phase that flips such a - // facet adds its key to INFO_NOT_FROM_CATALOG in the same diff — and only - // a non-BASELINE `info` facet may be exempted, since a BASELINE one is - // meant to stay the catalog's across phases. + // A phase that moves an `info` facet off this derivation on purpose adds + // its key to INFO_NOT_FROM_CATALOG in the same diff; only a + // non-BASELINE facet may be exempted, and today none is. const INFO_NOT_FROM_CATALOG = new Set(); for (const key of INFO_NOT_FROM_CATALOG) { expect(ROW_KEYS, `${key} is not a characterized row`).toContain(key); @@ -350,30 +380,36 @@ describe( if (INFO_NOT_FROM_CATALOG.has(key)) { continue; } - const [, id] = split(key); + const [plane, id] = split(key); const spec = catalogFor(key).find((model) => model.id === id); if (spec === undefined) { expect(id, key).toBe(UNKNOWN_ID); continue; } const stale = - `S0_TODAY's info for ${key} no longer matches the bundled catalog: ` + - "either a catalog refresh moved it (update the row in " + - "test/support/host.ts), or a phase flipped this facet off the " + - "catalog (add the key to INFO_NOT_FROM_CATALOG here)"; + `S0_TODAY's info for ${key} no longer matches the profile row and ` + + "the bundled catalog: either a catalog refresh moved it (update the " + + "row in test/support/host.ts), or a phase flipped this facet off " + + "them (add the key to INFO_NOT_FROM_CATALOG here)"; + const row = ( + CHARACTERIZED_PROFILE[plane as (typeof PLANES)[number]] + .models as readonly { + id: string; + contextWindow?: number; + }[] + ).find((model) => model.id === id); const info = S0_TODAY.rows[key].info as { context?: number; efforts?: readonly string[]; }; - expect(spec.context_window, stale).toEqual(info.context); - if (info.efforts === undefined) { - continue; - } - const map = thinkingLevelMapFor(spec); + expect(row?.contextWindow ?? spec.context_window, stale).toEqual( + info.context + ); + const protocol = protocolForSdk(spec.provider_npm); + expect(protocol, key).toBeDefined(); + const reasoning = reasoningFor(spec, protocol ?? "openai-completions"); expect( - map === undefined - ? [] - : PI_THINKING_LEVELS.filter((level) => map[level] !== null), + reasoning?.efforts.map((effort) => effort.id) ?? [], stale ).toEqual(info.efforts); compared += 1; @@ -383,32 +419,24 @@ describe( expect(compared).toBeGreaterThan(0); }); - it("fixture: the binary e2e's cell-aware helpers — the floating save-block pattern names any undescribed id and only those, todayRowsFor refuses an order it does not know, and a facet its pi-ai-first table moves is retagged with it, from KNOWN_DEFECT to BASELINE", () => { - // The pinned message is one the floating pattern accepts, so the two - // cells cannot disagree on a pinned run. + it("fixture: the handback's save-block pattern (test/host-planes.test.ts) names the stored gap id, todayRowsFor answers both orders the same and refuses one it does not know, and a facet whose value moves between the orders is exactly a facet whose tag moves", () => { const pinned = 'provider "opencode" model "gpt-5.3-codex-spark" needs an api'; expect(pinned).toMatch(S0_TODAY.saveBlockedByStoredGap); - expect(pinned).toMatch(SAVE_BLOCKED_BY_ANY_UNDESCRIBED); - // A floating pi that learned gpt-5.3-codex-spark names the next one. - expect(`provider "opencode" model "${UNKNOWN_ID}" needs an api`).toMatch( - SAVE_BLOCKED_BY_ANY_UNDESCRIBED - ); - // A described id, or the dot read as a wildcard, is not a match. - for (const other of ["deepseek-v4-pro", "gpt-5x3-codex-spark"]) { - expect(`provider "opencode" model "${other}" needs an api`).not.toMatch( - SAVE_BLOCKED_BY_ANY_UNDESCRIBED - ); - } + // The dot is a dot, not a wildcard. + expect( + 'provider "opencode" model "gpt-5x3-codex-spark" needs an api' + ).not.toMatch(S0_TODAY.saveBlockedByStoredGap); for (const order of APPLY_ORDERS) { expect(Object.keys(todayRowsFor(order))).toEqual(ROW_KEYS); + expect(todayRowsFor(order)).toEqual(S0_TODAY.rows); } // Structural, so no phase has to edit it: a facet whose VALUE differs // between the orders is exactly a facet whose TAG differs, and each one - // is a defect in the published order whose pi-ai-first value is already - // what P-R1 keeps. Which facets those are today is pinned by the - // [KNOWN_DEFECT] apply-order case below; once P-R1 collapses the - // override, both sets are empty and this still holds. + // would be a defect in the published order whose pi-ai-first value is + // already the behaviour to keep. P-R1 collapsed the override, so both + // sets are empty (the [BASELINE] apply-order case below pins that), and + // this keeps a future override honest. const piAiFirst = todayRowsFor("pi-ai-first"); const moved = ROW_KEYS.flatMap((key) => FACETS.filter( @@ -437,7 +465,7 @@ describe( } } // A probe JSON missing the field reads `undefined`; it must not select - // the pi-ai-first table. + // any table. expect(() => todayRowsFor(undefined as unknown as ApplyOrder)).toThrow( /unknown apply order/u ); @@ -447,71 +475,50 @@ describe( await expectSignInReadsTheZenPlane(); }); - it("[INTENDED_CHANGE] registry: both planes are pi-ai's, and six hidden routes are registered and hidden from listProviders (P-R1 claims the planes)", () => { + it("[BASELINE] registry: both planes are the plugin's adapter, pi-ai keeps its other route, no hidden route exists, and Settings still addresses the planes under pi-ai's namespace (D13)", () => { const { llm } = booted(); const registry = adaptersOf(llm); - expect([...registry.keys()].toSorted()).toEqual( - ["deepseek", "opencode", "opencode-go", ...INTERNAL_ROUTES].toSorted() - ); + expect([...registry.keys()].toSorted()).toEqual([ + "deepseek", + "opencode", + "opencode-go", + ]); expect( llm .listProviders() .map((provider) => provider.id) .toSorted() ).toEqual(["deepseek", "opencode", "opencode-go"]); - for (const route of [...PLANES, ...INTERNAL_ROUTES]) { - expect(registry.get(route)?.adapter.constructor.name).toBe( - "PiAiAdapter" - ); - } - // pi-ai's own adapter serves the planes; the hidden routes are not it. - const piAdapter = registry.get("opencode")?.adapter; - expect(registry.get("opencode-go")?.adapter).toBe(piAdapter); - for (const route of INTERNAL_ROUTES) { - expect(registry.get(route)?.adapter).not.toBe(piAdapter); - } + expectPlanesOwned(); const directory = llm.listConfigurableProviders(); for (const plane of PLANES) { - expect( - directory.find((entry) => entry.provider === plane) - ).toMatchObject({ + const entry = directory.find((row) => row.provider === plane); + expect(entry).toMatchObject({ settingsNs: "llm-pi-ai", settingsPath: ["providers", plane], }); + // pi-ai no longer resolves the planes, so it reports no gap id. + expect(entry?.error).toBeUndefined(); } - // The directory reports each plane's first stored gap id. - expect( - directory.find((entry) => entry.provider === "opencode")?.error - ).toMatch(/model "gpt-5\.3-codex-spark" needs an api/u); - expect( - directory.find((entry) => entry.provider === "opencode-go")?.error - ).toMatch(/model "gemini-4-argon" needs an api/u); }); - it("[INTENDED_CHANGE] keyless listing: before a key is captured, the listing patch hides every paid model on both planes (D21)", async () => { + it("[BASELINE] listing before any turn: every declared id is listed on both planes (D21)", async () => { const { llm } = booted(); for (const plane of PLANES) { - expect(await llm.listModels(plane)).toEqual( - S0_TODAY.keylessListing[plane] + expect(modelIds(await llm.listModels(plane))).toEqual( + S0_TODAY.listingBeforeTurn[plane] ); } }); - it("[KNOWN_DEFECT] keyed listing: once a turn captured a key, only pi-resolved ids are listed — every gap id and the declared unknown id are missing (G1)", async () => { + it("[BASELINE] listing after a turn on each plane: every declared id is listed, gap ids and the unknown id included (G1)", async () => { const { llm } = booted(); await sentBy("opencode", "deepseek-v4-pro"); await sentBy("opencode-go", "deepseek-v4-pro"); - const listed: Record = {}; - for (const plane of PLANES) { - const models = await llm.listModels(plane); - listed[plane] = models.map((model) => model.id); - } - expect(listed).toEqual(S0_TODAY.keyedListing); - expect(listed.opencode).not.toContain("gpt-5.3-codex-spark"); - expect(listed["opencode-go"]).not.toContain("gemini-4-argon"); - expect(listed["opencode-go"]).not.toContain("minimax-m3.1-flash"); for (const plane of PLANES) { - expect(listed[plane]).not.toContain(UNKNOWN_ID); + const listed = modelIds(await llm.listModels(plane)); + expect(listed).toEqual(S0_TODAY.listingAfterTurn[plane]); + expect(listed).toContain(UNKNOWN_ID); } }); @@ -524,7 +531,7 @@ describe( } ); - it("[KNOWN_DEFECT] the toggle-only model is offered Minimal/Xhigh/Max (D1)", async () => { + it("[BASELINE] the toggle-only model is offered Off / On, and a level it does not take is refused (D1)", async () => { // Chosen from the bundled catalog, not by name: on/off reasoning, no levels. const spec = OPENCODE_GO_CATALOG.find( (model) => model.id === "minimax-m3" @@ -534,20 +541,33 @@ describe( "opencode-go", "minimax-m3" ); - const efforts = info.reasoning?.efforts?.map((effort) => effort.id) ?? []; - expect(efforts).toEqual( - S0_TODAY.rows["opencode-go/minimax-m3"].info.efforts - ); - expect(efforts).toEqual( - expect.arrayContaining(["minimal", "xhigh", "max"]) - ); - expect(efforts).not.toContain("off"); + expect(info.reasoning?.efforts?.map((effort) => effort.id)).toEqual([ + "off", + "high", + ]); + expect(info.reasoning?.efforts?.map((effort) => effort.name)).toEqual([ + "Off", + "On", + ]); + await expect( + booted().llm.resolveCallConfig({ + model: "minimax-m3", + provider: "opencode-go", + reasoningEffort: "minimal", + } as never) + ).rejects.toMatchObject({ code: "UNSUPPORTED_REASONING_EFFORT" }); }); - it("[INTENDED_CHANGE] completions models dispatch to pi 1.1.0's legacy bases", async () => { + it("[BASELINE] completions models dispatch to the modern (per-protocol inference) family on both planes", async () => { for (const [route, wireUrl] of [ - ["opencode", "https://opencode.ai/zen/v1/chat/completions"], - ["opencode-go", "https://opencode.ai/zen/go/v1/chat/completions"], + [ + "opencode", + "https://opencode.ai/inference/openai/v1/chat/completions", + ], + [ + "opencode-go", + "https://opencode.ai/inference/go/openai/v1/chat/completions", + ], ] as const) { const { drained, requests } = await sentBy(route, "deepseek-v4-pro"); expect(requests.map((request) => request.url)).toEqual([wireUrl]); @@ -555,60 +575,53 @@ describe( } }); - it("[KNOWN_DEFECT] in this order the adapter decoration never runs: pi-ai's plane adapter is not wrapped, so its prepared call carries no repaired base (decoratePlaneAdapters in src/models-discovery.ts)", async () => { - // decoratePlaneAdapters wraps the adapters the registry holds when the - // plugin's effect runs, and here pi-ai has not registered yet. - const adapter = planeAdapter(); - expect(Object.hasOwn(adapter, "prepareCall")).toBe(false); - expect(Object.hasOwn(adapter, "resolveModel")).toBe(false); - const prepared = await adapter.prepareCall("opencode", "deepseek-v4-pro"); - expect(prepared.baseUrl).toBeUndefined(); + it("[BASELINE] the plugin patches nothing on its adapter: it is its own PlaneAdapter, whose methods are its class's", () => { + const adapter = adapterOf("opencode"); + expect(adapter?.constructor.name).toBe("PlaneAdapter"); + expect(Object.hasOwn(adapter ?? {}, "prepareCall")).toBe(false); + expect(Object.hasOwn(adapter ?? {}, "resolveModel")).toBe(false); }); - it("[KNOWN_DEFECT] Zen gpt-5.3-codex-spark: the user's row is ignored and resolveCallConfig refuses what dispatch serves through the hidden Responses route", async () => { + it("[BASELINE] Zen gpt-5.3-codex-spark: the user's row reaches model info, resolveCallConfig accepts it, and it is sent on Responses (D17)", async () => { const { llm } = booted(); const info = await llm.resolveModelInfo( "opencode", "gpt-5.3-codex-spark" ); - // The row declares name "My Spark" and contextWindow 400000. - expect(info.context?.contextWindow).toBe(128_000); - expect(info.name).not.toBe("My Spark"); + expect(info.name).toBe("My Spark"); + expect(info.context?.contextWindow).toBe(400_000); await expect( llm.resolveCallConfig({ model: "gpt-5.3-codex-spark", provider: "opencode", } as never) - ).rejects.toMatchObject({ code: "INVALID_CONFIG" }); + ).resolves.toBeDefined(); const { requests } = await sentBy("opencode", "gpt-5.3-codex-spark"); expect(requests.map((request) => request.url)).toEqual([ "https://opencode.ai/inference/openai/v1/responses", ]); }); - it("[KNOWN_DEFECT] Go minimax-m3.1-flash answers INVALID_CONFIG 'needs an api' and sends nothing (F4)", async () => { + it("[BASELINE] Go minimax-m3.1-flash validates and is sent once as completions on Go's modern base (G1)", async () => { const { llm } = booted(); await expect( llm.resolveCallConfig({ model: "minimax-m3.1-flash", provider: "opencode-go", } as never) - ).rejects.toMatchObject({ - code: "INVALID_CONFIG", - message: expect.stringMatching(/"minimax-m3\.1-flash" needs an api/u), - }); + ).resolves.toBeDefined(); const { drained, requests } = await sentBy( "opencode-go", "minimax-m3.1-flash" ); - expect(requests).toEqual([]); - expect(drained.finish).toEqual({ code: "INVALID_CONFIG", kind: "error" }); + expect(requests.map((request) => request.url)).toEqual([ + "https://opencode.ai/inference/go/openai/v1/chat/completions", + ]); + expect(drained.finish).toEqual({ code: "AUTH", kind: "error" }); }); - it("[KNOWN_DEFECT] Go gemini-4-argon: resolveCallConfig refuses it (INVALID_CONFIG) while the turn is served on the Go Google base through the bridge", async () => { - expect(await callConfigOf("opencode-go", "gemini-4-argon")).toBe( - "INVALID_CONFIG" - ); + it("[BASELINE] Go gemini-4-argon validates and is sent on Go's Google base with the Go key (F10)", async () => { + expect(await callConfigOf("opencode-go", "gemini-4-argon")).toBe("ok"); const { drained, requests } = await sentBy( "opencode-go", "gemini-4-argon" @@ -621,42 +634,53 @@ describe( expect(drained.finish).toEqual({ code: "PI_AI_ERROR", kind: "error" }); }); - it("[KNOWN_DEFECT] a stored gap id blocks every save of its plane: appending a new id and a displayName change are both refused naming the STORED id, and the file is unchanged (F4; llm-pi-ai config.ts:364-368, catalog.ts:917-921)", async () => { - const { ctx, piEntry, profile } = booted(); - const before = readFileSync(profile.patchPath); - await expect( - ctx.configEditor.edit(piEntry, appendTo("opencode", SAVE_CANDIDATE)) - ).rejects.toThrow(S0_TODAY.saveBlockedByStoredGap); - await expect( - ctx.configEditor.edit(piEntry, (current) => { - const providers = current.providers as Record; - return { - ...current, - providers: { - ...providers, - opencode: { ...providers.opencode, displayName: "Zen (edited)" }, - }, - }; - }) - ).rejects.toThrow(S0_TODAY.saveBlockedByStoredGap); - expect(readFileSync(profile.patchPath)).toEqual(before); - }); - - it("[KNOWN_DEFECT] the probe's save sequence: dropping the plane's undescribed ids is accepted, then the catalog-unknown id itself is what the check names", async () => { - // The binary e2e's probe runs exactly this, so its rows isolate the - // unknown id although every plane of the profile stores a gap id. - const { ctx, piEntry, profile } = booted(); + it("[BASELINE] a stored gap id blocks no save: a catalog-unknown id is appended and a displayName changed, the file holds both, and the new id is listed, validates and is sent as completions (D15)", async () => { + const host = booted(); + const { ctx, llm, piEntry } = host; await ctx.configEditor.edit( piEntry, - dropFrom("opencode", ZEN_UNDESCRIBED) + appendTo("opencode", SAVE_CANDIDATE) ); - const cleared = readFileSync(profile.patchPath, "utf8"); - expect(cleared).not.toContain("gpt-5.3-codex-spark"); - expect(cleared).toContain("gemini-4-argon"); - await expect( - ctx.configEditor.edit(piEntry, appendTo("opencode", SAVE_CANDIDATE)) - ).rejects.toThrow(S0_TODAY.saveRejectedUnknown); - expect(readFileSync(profile.patchPath, "utf8")).toBe(cleared); + expect( + savedPiRow(host).opencode?.models?.map((model) => model.id) + ).toEqual([...CHARACTERIZED_MODELS.opencode, SAVE_CANDIDATE]); + await ctx.configEditor.edit(piEntry, (current) => { + const providers = current.providers as Record; + return { + ...current, + providers: { + ...providers, + opencode: { ...providers.opencode, displayName: "Zen (edited)" }, + }, + }; + }); + await until( + () => + llm.listProviders().find((provider) => provider.id === "opencode") + ?.name === "Zen (edited)", + "the displayName edit reaches the registry" + ); + expect(modelIds(await llm.listModels("opencode"))).toContain( + SAVE_CANDIDATE + ); + expect(await callConfigOf("opencode", SAVE_CANDIDATE)).toBe("ok"); + const { requests } = await sentBy("opencode", SAVE_CANDIDATE); + expect(requests.map((request) => request.url)).toEqual([ + "https://opencode.ai/inference/openai/v1/chat/completions", + ]); + }); + + it("[BASELINE] the probe's save sequence: the first append of the catalog-unknown id is accepted, with no gap id dropped first", async () => { + // The binary e2e's probe runs exactly this. + const host = booted(); + await host.ctx.configEditor.edit( + host.piEntry, + appendTo("opencode", SAVE_CANDIDATE) + ); + const saved = readFileSync(host.profile.patchPath, "utf8"); + expect(saved).toContain(SAVE_CANDIDATE); + expect(saved).toContain("gpt-5.3-codex-spark"); + expect(saved).toContain(UNKNOWN_ID); }); } ); @@ -671,42 +695,29 @@ describe( await expectSignInReadsTheZenPlane(); }); - it("[INTENDED_CHANGE] the adapter decoration runs, and the base it repairs never reaches the wire", async () => { - const adapter = planeAdapter(); - expect(Object.hasOwn(adapter, "prepareCall")).toBe(true); + it("[BASELINE] pi-ai held the planes first, so the claim restarted it exactly once; both planes are the plugin's, and completions reach each plane's own modern base", async () => { + expect(restarts?.count()).toBe(1); + expectPlanesOwned(); for (const [route, wireUrl] of [ - ["opencode", "https://opencode.ai/zen/v1/chat/completions"], - ["opencode-go", "https://opencode.ai/zen/go/v1/chat/completions"], + [ + "opencode", + "https://opencode.ai/inference/openai/v1/chat/completions", + ], + [ + "opencode-go", + "https://opencode.ai/inference/go/openai/v1/chat/completions", + ], ] as const) { - // Measured: the Go plane's repaired base is ZEN's. pi-ai serves both - // planes from ONE adapter instance, which decoratePlaneAdapters wraps - // once per route, so the Go wrapper runs over the Zen one and keeps the - // base it already filled. - const decorated = "https://opencode.ai/inference/openai/v1"; - const prepared = await adapter.prepareCall(route, "deepseek-v4-pro"); - expect(prepared).toMatchObject({ - api: "openai-completions", - baseUrl: decorated, - }); const { drained, requests } = await sentBy(route, "deepseek-v4-pro"); expect(requests.map((request) => request.url)).toEqual([wireUrl]); - // Measured, not compared as literals: the URL the turn sent does not - // start with the base the decorated adapter prepared. - expect(requests[0]?.url.startsWith(String(prepared.baseUrl))).toBe( - false - ); expect(drained.finish).toEqual({ code: "AUTH", kind: "error" }); } }); - // The plan expected INVALID_CONFIG for Go Gemini; that holds in the - // published host's order (the plugin-first block, where the case is - // [KNOWN_DEFECT]) and not in this one, where the wrapped plane adapter - // delegates to opencode-go-google (decoratePlaneAdapters in - // src/models-discovery.ts), so here it is [BASELINE]. todayRowsFor is the - // table the binary e2e picks when its probe infers this order; these cases - // measure every facet of it, and the apply-order case below pins that - // only that one facet moves. + // With the plugin owning the planes the claim answers the same in this + // order, so this order's table is S0_TODAY's (todayRowsFor), every facet + // of it is measured here under its own tag, and the apply-order case below + // pins that no facet moves. it.each(facetCasesFor("pi-ai-first"))( "[$tag] $key · $facet", async ({ facet, key }) => { @@ -719,25 +730,26 @@ describe( ); describe("characterization · the apply order (no host)", () => { - // Pure: it reads the two tables the binary e2e picks between, and boots - // nothing. Each order's generated facet cases measure its table; this case - // pins WHERE they differ today. The dependence on the order is the defect: - // when P-R1 flips this row it drops todayRowsFor's pi-ai-first override in - // the same diff, and this case becomes "[BASELINE] no facet depends on the - // apply order" (CONTRIBUTING.md, "Tags"). - it("[KNOWN_DEFECT] the apply order decides exactly one facet today: Go gemini-4-argon's callConfig is INVALID_CONFIG in the published order and \"ok\" with pi-ai first (P-R1 drops todayRowsFor's pi-ai-first override with this row's flip)", () => { + // Pure: it reads the two tables the binary e2e and the two booted blocks + // pick between, and boots nothing. Each order's generated facet cases + // measure its table; this case pins that they no longer differ. P-T1b held + // it as "[KNOWN_DEFECT] the apply order decides exactly one facet" (Go + // gemini-4-argon's callConfig); P-R1 flipped that row and + // dropped todayRowsFor's pi-ai-first override in the same diff + // (CONTRIBUTING.md, "Tags"). + it("[BASELINE] no facet depends on the apply order: todayRowsFor answers S0_TODAY's rows, with S0_TODAY's tags, in both orders", () => { const piAiFirst = todayRowsFor("pi-ai-first"); + expect(todayRowsFor("plugin-first")).toEqual(S0_TODAY.rows); + expect(piAiFirst).toEqual(S0_TODAY.rows); const moved = ROW_KEYS.flatMap((key) => FACETS.filter( (facet) => JSON.stringify(expectedFacet(facet, key, piAiFirst)) !== - JSON.stringify(expectedFacet(facet, key)) + JSON.stringify(expectedFacet(facet, key)) || + piAiFirst[key].tags[facet] !== S0_TODAY.rows[key].tags[facet] ).map((facet) => `${key} · ${facet}`) ); - expect(moved).toEqual(["opencode-go/gemini-4-argon · callConfig"]); - const key = "opencode-go/gemini-4-argon"; - expect(expectedFacet("callConfig", key)).toBe("INVALID_CONFIG"); - expect(expectedFacet("callConfig", key, piAiFirst)).toBe("ok"); + expect(moved).toEqual([]); }); }); @@ -745,7 +757,7 @@ describe( "characterization · a gap-free plane (tier B)", { timeout: 30_000 }, () => { - it("[KNOWN_DEFECT] the unknown id itself is what the save check names when the plane stores no other gap", async () => { + it("[BASELINE] the catalog-unknown id is accepted by the save check, and the file holds it (D15)", async () => { // Plain S0 (no declared unknown id) minus Zen's stored gap id. const providers = s0Providers(); const zen = providers.opencode as { models: { id: string }[] }; @@ -757,12 +769,13 @@ describe( wire = recordingFetch(); vi.stubGlobal("fetch", wire.fetch); tier = await bootTierB({ providers }); + await waitForPlaneOwner(tier.llm); const { ctx, piEntry, profile } = tier; - const before = readFileSync(profile.patchPath); - await expect( - ctx.configEditor.edit(piEntry, appendTo("opencode", SAVE_CANDIDATE)) - ).rejects.toThrow(S0_TODAY.saveRejectedUnknown); - expect(readFileSync(profile.patchPath)).toEqual(before); + await ctx.configEditor.edit( + piEntry, + appendTo("opencode", SAVE_CANDIDATE) + ); + expect(readFileSync(profile.patchPath, "utf8")).toContain(SAVE_CANDIDATE); }); } ); diff --git a/test/host-editor-contracts.test.ts b/test/host-editor-contracts.test.ts index 2f2698f5..bfa92564 100644 --- a/test/host-editor-contracts.test.ts +++ b/test/host-editor-contracts.test.ts @@ -20,16 +20,22 @@ import { readFileSync } from "node:fs"; import type { Context, Fiber } from "@deepseek-ai/cordis"; import { readProfilePatches } from "@deepseek-ai/dsh-app-boot"; +import * as LlmPiAi from "@deepseek-ai/dsh-llm-pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + adaptersOf, bootTierB, + drainStream, fakeCredentials, + modelsPageRows, planeKeysOnly, + PLANES, providerWithApiKey, recordingFetch, s0Providers, type TierB, + waitForPlaneOwner, waterfallSpy, withoutPlanes, } from "./support/host.ts"; @@ -458,3 +464,114 @@ describe( }); } ); + +/** pi-ai's descriptor value for a provider, as `settings.describe()` builds it. */ +const describedEntry = (host: TierB, provider: string): unknown => + ( + host.ctx.settings + .describe() + .find((descriptor) => descriptor.ns === "llm-pi-ai")?.value as + | { providers?: Record } + | undefined + )?.providers?.[provider]; + +/** pi-ai's own resolved profile for a route, without its built provider. */ +const piAiProfile = (host: TierB, route: string): Record => { + const adapter = adaptersOf(host.llm).get(route)?.adapter as unknown as { + config: { profiles: () => ReadonlyMap> }; + }; + const { piProvider: _provider, ...rest } = + adapter.config.profiles().get(route) ?? {}; + return rest; +}; + +describe( + "contract · the plugin's Settings graft and pi-ai's own routes (tier B, D18)", + { timeout: 30_000 }, + () => { + it("the graft gives each plane exactly the entry pi-ai's Config resolves from the file, the value a plugin-less boot shows, and leaves the revision alone", async () => { + vi.stubGlobal("fetch", recordingFetch().fetch); + tier = await bootTierB({ providers: s0Providers() }); + await waitForPlaneOwner(tier.llm); + const resolved = ( + LlmPiAi.Config({ providers: s0Providers() } as never) as unknown as { + providers: { get: () => Record }; + } + ).providers.get(); + const grafted = Object.fromEntries( + PLANES.map((plane) => [plane, describedEntry(tier!, plane)]) + ); + for (const plane of PLANES) { + expect(grafted[plane], plane).toEqual(resolved[plane]); + } + /** What the graft must not touch: the descriptor's revision, base and user layers. */ + const untouched = (host: TierB) => { + const descriptor = host.ctx.settings + .describe() + .find((entry) => entry.ns === "llm-pi-ai") as + | { revision?: unknown; base?: unknown; user?: unknown } + | undefined; + return { + base: descriptor?.base, + revision: descriptor?.revision, + user: descriptor?.user, + }; + }; + const withPlugin = untouched(tier); + expect(withPlugin.revision).toBeDefined(); + expect(withPlugin.user).toBeDefined(); + await tier.dispose(); + tier = await bootTierB({ plugin: false, providers: s0Providers() }); + for (const plane of PLANES) { + expect(describedEntry(tier, plane), plane).toEqual(grafted[plane]); + } + // The same profile booted without the plugin: the same revision, and + // the same base and user layers, so the graft changed only `value`. + expect(untouched(tier)).toEqual(withPlugin); + }); + + it("the Models page reads both planes configured under the profile's own apiKeyEnv, a custom one included, and a key typed under it authenticates the plane", async () => { + vi.stubGlobal("fetch", recordingFetch().fetch); + tier = await bootTierB({ providers: s0Providers() }); + await waitForPlaneOwner(tier.llm); + expect(modelsPageRows(tier)).toEqual({ + opencode: { apiKeyEnv: "OPENCODE_API_KEY", configured: true }, + "opencode-go": { apiKeyEnv: "OPENCODE_GO_API_KEY", configured: true }, + }); + await tier.dispose(); + const wire = recordingFetch(); + vi.stubGlobal("fetch", wire.fetch); + const providers = s0Providers(); + providers.opencode!.apiKeyEnv = "MY_ZEN_KEY"; + const credentials = fakeCredentials(); + tier = await bootTierB({ credentials, providers }); + await waitForPlaneOwner(tier.llm); + expect(modelsPageRows(tier).opencode).toEqual({ + apiKeyEnv: "MY_ZEN_KEY", + configured: true, + }); + await ( + credentials.service.set as unknown as ( + ref: string, + value: string + ) => Promise + )("MY_ZEN_KEY", "typed"); + await drainStream(tier.llm, "opencode", "deepseek-v4-pro"); + const sent = wire.requests.find( + (request) => !request.url.startsWith("https://models.opencode.ai/") + ); + expect(sent?.headers.authorization).toBe("Bearer typed".length); + }); + + it("parity on a route that is not a plane: pi-ai's resolved deepseek profile is the same with and without the plugin", async () => { + vi.stubGlobal("fetch", recordingFetch().fetch); + tier = await bootTierB({ providers: s0Providers() }); + await waitForPlaneOwner(tier.llm); + const withPlugin = piAiProfile(tier, "deepseek"); + await tier.dispose(); + tier = await bootTierB({ plugin: false, providers: s0Providers() }); + expect(withPlugin).toEqual(piAiProfile(tier, "deepseek")); + expect(Object.keys(withPlugin)).toContain("retryPolicy"); + }); + } +); diff --git a/test/host-harness.test.ts b/test/host-harness.test.ts index 4d122a8a..a9ffc20a 100644 --- a/test/host-harness.test.ts +++ b/test/host-harness.test.ts @@ -2,10 +2,10 @@ * 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, 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 — including that tier A's plugin is a DEGRADED one. So are + * 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. * @@ -22,14 +22,18 @@ import { fakeCredentials, mountTierA, PLANES, + piAiRestarts, recordingFetch, S0_TODAY, s0Providers, type TierA, type TierB, - waitForRoutes, + waitForPlaneOwner, } from "./support/host.ts"; -import { INTERNAL_ROUTES, loadPiAiAdapterHost } 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; @@ -51,23 +55,6 @@ afterEach(async () => { const ACTIVE = 2; const FAILED = 3; -/** Poll until `predicate` holds, or fail after `deadlineMs`. */ -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); - }); - } -}; - /** A two-chunk chat-completions stream that ends with `stop`. */ const completionStream = (model: string): Response => { const chunk = (delta: object, finish: string | null) => @@ -88,43 +75,35 @@ describe( "host harness · its seams (tiers A and B)", { timeout: 30_000 }, () => { - it("tier A with the plugin: it applies and patches fetch, but WITHOUT a loader it cannot find pi-ai, so no hidden route mounts; disposal restores fetch", async () => { - const info = vi.spyOn(console, "info").mockImplementation(() => { - // Silenced: the case reads the calls, not the console. + 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" }] } }, + 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); - // The plugin finds pi-ai through the loader's entries - // (`piAiEntry` in src/responses-provider.ts), and tier A has no loader: the mount - // reports that and stands down. A P-R1 case that needs the real plugin - // beside pi-ai belongs in tier B. - await until( - () => - info.mock.calls.some((call) => - String(call[0]).includes("no loaded llm-pi-ai in this composition") - ), - "the mount's stand-down line" - ); - const registry = adaptersOf(tierA.llm); - for (const route of INTERNAL_ROUTES) { - expect(registry.has(route), route).toBe(false); - } - expect([...registry.keys()]).toEqual(["opencode"]); - // The Google bridge's absence is pinned by its CAUSE, not its outcome: - // this case declares no Google model, so an empty registry above would - // hold for that reason alone. The bridge builds its adapter from the - // record `loadPiAiAdapterHost` answers (src/responses-provider.ts), and - // registerGoogleBridge stands down without its `PiAiAdapter` - // (src/google-bridge.ts) — and without a loader there is no record. - await expect(loadPiAiAdapterHost(tierA.ctx)).resolves.toBeUndefined(); - await tierA.dispose(); - tierA = undefined; + // 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); }); @@ -210,14 +189,13 @@ describe( }), providers: characterizedProviders(), }); - await waitForRoutes(tierB.llm, ["opencode-responses"]); + await waitForPlaneOwner(tierB.llm); expect(process.env.OPENCODE_API_KEY).toBe(""); expect(process.env.OPENCODE_GO_API_KEY).toBe(""); - // The plugin reads the environment for its keyless listing filter: with - // the shell's key left in place, the paid models would be listed here. + // The listing no longer depends on a key (D21): every declared id. for (const plane of PLANES) { - expect(await tierB.llm.listModels(plane), plane).toEqual( - S0_TODAY.keylessListing[plane] + expect(modelIds(await tierB.llm.listModels(plane)), plane).toEqual( + S0_TODAY.listingBeforeTurn[plane] ); } await tierB.dispose(); diff --git a/test/host-modules.test.ts b/test/host-modules.test.ts new file mode 100644 index 00000000..779dd271 --- /dev/null +++ b/test/host-modules.test.ts @@ -0,0 +1,560 @@ +/** + * Where the plugin reads the host's modules from (`src/host/host-modules.ts`) + * and the seam that lets the harness replace it (`src/cordis-context.ts`). + * + * The default source is exercised against this repository's own install — + * the same walk from the wrapper's directory a deployment takes — so a case + * here proves the plugin finds pi, dsh-llm and the loader where pi-ai runs on + * them, and pi-ai's own module on its loader entry. + * + * @module test/host-modules.test + */ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import * as LlmPiAi from "@deepseek-ai/dsh-llm-pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { hostModulesForTests } from "./support/host.ts"; +import { + asPiAiModule, + defaultHostModuleSource, + loadHostModules, + loadHostPi, + loadLibrariesFrom, + PI_AI_RUNTIME, + piAiFibers, + readLaunchEnvironment, + replaceHostModuleSource, + wrapperEntryFor, +} from "./support/src.ts"; + +const roots: string[] = []; + +afterEach(() => { + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +/** Write `files` (path → contents) under `root`. */ +const writeTree = (root: string, files: Record): void => { + for (const [file, contents] of Object.entries(files)) { + const target = path.join(root, file); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, contents); + } +}; + +const esm = (body: string): string => `${body}\n`; +const pkg = (manifest: Record): string => + JSON.stringify({ type: "module", ...manifest }); + +/** + * A stand-in install: a profile-like directory whose `node_modules` holds the + * wrapper, a pi carrying every module the plane provider reads, and dsh-llm. + * `omit` drops files, `files` adds or replaces them. + */ +const standInInstall = ( + options: { + readonly omit?: readonly string[]; + readonly files?: Record; + } = {} +): string => { + // Canonical, as `wrapperEntryFor` realpaths the entry it finds: on macOS + // the temporary directory is reached through a symlink. + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "host-modules-"))); + roots.push(root); + const pi = "node_modules/@earendil-works/pi-ai"; + const files: Record = { + "node_modules/@deepseek-ai/dsh-llm-pi-ai/package.json": pkg({ + exports: { ".": { default: "./lib/index.js" } }, + name: "@deepseek-ai/dsh-llm-pi-ai", + }), + "node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js": esm( + "export const wrapper = true;" + ), + "node_modules/@deepseek-ai/dsh-llm/package.json": pkg({ + main: "index.js", + }), + "node_modules/@deepseek-ai/dsh-llm/index.js": esm( + "export const resolveRetryPolicy = () => ({});" + ), + [`${pi}/package.json`]: pkg({ version: "9.9.9" }), + [`${pi}/dist/providers/all.js`]: esm( + 'export const getBuiltinModels = () => [{ id: "stand-in" }];' + ), + [`${pi}/dist/api/anthropic-messages.lazy.js`]: esm( + "export const anthropicMessagesApi = () => ({});" + ), + [`${pi}/dist/api/google-generative-ai.lazy.js`]: esm( + "export const googleGenerativeAIApi = () => ({});" + ), + [`${pi}/dist/api/openai-completions.lazy.js`]: esm( + "export const openAICompletionsApi = () => ({});" + ), + [`${pi}/dist/api/openai-responses.lazy.js`]: esm( + "export const openAIResponsesApi = () => ({});" + ), + [`${pi}/dist/providers/opencode-headers.js`]: esm( + "export const withOpenCodeSessionHeader = (api) => api;" + ), + [`${pi}/dist/models.js`]: esm( + "export const createProvider = (input) => input;" + ), + [`${pi}/dist/providers/opencode.js`]: esm( + "export const opencodeProvider = () => ({ auth: { zen: true } });" + ), + [`${pi}/dist/providers/opencode-go.js`]: esm( + "export const opencodeGoProvider = () => ({ auth: { go: true } });" + ), + ...options.files, + }; + const omit = new Set(options.omit); + writeTree( + root, + Object.fromEntries( + Object.entries(files).filter(([file]) => !omit.has(file)) + ) + ); + return root; +}; + +const baseOf = (root: string): string => pathToFileURL(`${root}/`).href; +const entryIn = (root: string): string | undefined => + wrapperEntryFor({ baseUrl: baseOf(root) }); + +describe("host-modules · the host's tree, found from the context's base URL", () => { + it("resolves the wrapper from the base URL the loader resolves from, else from the plugin's own location", () => { + const root = standInInstall(); + const own = wrapperEntryFor({}); + // The checkout's own copy: what a bare context, a base it cannot be + // resolved from, or a base that throws all fall back to. + expect(own).toContain(`${path.sep}dsh-llm-pi-ai${path.sep}`); + expect(entryIn(root)?.startsWith(root)).toBe(true); + expect(entryIn(root)).not.toBe(own); + expect(wrapperEntryFor({ baseUrl: "file:///no-such-root/" })).toBe(own); + expect(wrapperEntryFor({ baseUrl: 5 })).toBe(own); + const throwing = {}; + Object.defineProperty(throwing, "baseUrl", { + get: () => { + throw new Error("no inject"); + }, + }); + expect(wrapperEntryFor(throwing)).toBe(own); + }); + + it("loads the libraries from that wrapper's tree, and reports which pi it loaded", async () => { + const root = standInInstall(); + const libraries = await loadLibrariesFrom(entryIn(root)); + expect(libraries?.version).toBe("9.9.9"); + expect(libraries?.root).toBe( + path.join(root, "node_modules", "@earendil-works", "pi-ai") + ); + expect(libraries?.opencodeAuth).toEqual({ zen: true }); + expect(libraries?.opencodeGoAuth).toEqual({ go: true }); + expect(libraries?.builtinModels("opencode")).toEqual([{ id: "stand-in" }]); + // No loader in this tree, and a dsh-llm without its optional helpers. + expect(libraries?.interpolate).toBeUndefined(); + expect(libraries?.LlmError).toBeUndefined(); + expect(libraries?.assertUsableApiKey).toBeUndefined(); + // The default source keys its cache by the wrapper it resolved, so the + // same base answers the same load and another base its own. + const viaSource = await defaultHostModuleSource.libraries({ + baseUrl: baseOf(root), + }); + expect(viaSource?.root).toBe(libraries?.root); + expect( + await defaultHostModuleSource.libraries({ baseUrl: baseOf(root) }) + ).toBe(viaSource); + const own = await defaultHostModuleSource.libraries({}); + expect(own?.version).toBe("1.1.0"); + const pi = await loadHostPi({ baseUrl: baseOf(root) }); + expect(pi?.version).toBe("9.9.9"); + expect(pi?.root).toBe(libraries?.root); + }); + + it("answers no libraries for a tree missing pi, dsh-llm or one of pi's exports; a manifest without a version reads as empty", async () => { + expect(await loadLibrariesFrom()).toBeUndefined(); + const noPi = standInInstall({ + omit: ["node_modules/@earendil-works/pi-ai/package.json"], + }); + expect(await loadLibrariesFrom(entryIn(noPi))).toBeUndefined(); + expect(await loadHostPi({ baseUrl: baseOf(noPi) })).toBeUndefined(); + const noLlm = standInInstall({ + omit: ["node_modules/@deepseek-ai/dsh-llm/package.json"], + }); + expect(await loadLibrariesFrom(entryIn(noLlm))).toBeUndefined(); + const movedExport = standInInstall({ + files: { + "node_modules/@earendil-works/pi-ai/dist/providers/all.js": esm( + "export const somethingElse = 1;" + ), + }, + }); + expect(await loadLibrariesFrom(entryIn(movedExport))).toBeUndefined(); + expect(await loadHostPi({ baseUrl: baseOf(movedExport) })).toBeUndefined(); + const throwingAuth = standInInstall({ + files: { + "node_modules/@earendil-works/pi-ai/dist/providers/opencode.js": esm( + 'export const opencodeProvider = () => { throw new Error("no"); };' + ), + }, + }); + expect(await loadLibrariesFrom(entryIn(throwingAuth))).toBeUndefined(); + const unreadableLlm = standInInstall({ + files: { + "node_modules/@deepseek-ai/dsh-llm/package.json": "{not json", + }, + }); + expect(await loadLibrariesFrom(entryIn(unreadableLlm))).toBeUndefined(); + const stringExports = standInInstall({ + files: { + "node_modules/@deepseek-ai/dsh-llm/package.json": pkg({ + exports: "./index.js", + }), + "node_modules/@earendil-works/pi-ai/package.json": pkg({}), + }, + }); + const loaded = await loadLibrariesFrom(entryIn(stringExports)); + expect(loaded?.version).toBe(""); + const stringPi = await loadHostPi({ baseUrl: baseOf(stringExports) }); + expect(stringPi?.version).toBe(""); + const brokenManifest = standInInstall({ + files: { + "node_modules/@earendil-works/pi-ai/package.json": "{not json", + }, + }); + const broken = await loadLibrariesFrom(entryIn(brokenManifest)); + expect(broken?.version).toBe(""); + expect( + await loadHostPi({ baseUrl: baseOf(brokenManifest) }) + ).toBeUndefined(); + }); +}); + +describe("host-modules · the Google level pieces (D16)", () => { + const pi = "node_modules/@earendil-works/pi-ai"; + /** + * Counts each import of a piece module. In pi, two of the three pull in the + * Google SDK, so they must load on a level model's first call, never with + * the libraries; `lazy.js` and `models.js` load eagerly by design and carry + * no counter. Each stand-in install is a fresh root, so a fresh module. + */ + const LOADS = "__dshLevelLoads"; + const counted = (body: string): string => + esm(`globalThis.${LOADS} = (globalThis.${LOADS} ?? 0) + 1;\n${body}`); + const loads = (): unknown => Reflect.get(globalThis, LOADS); + /** pi's level pieces, each a stand-in export. */ + const levelFiles: Record = { + [`${pi}/dist/api/lazy.js`]: esm( + "export const lazyStream = (model, setup) => setup();" + ), + [`${pi}/dist/models.js`]: esm( + "export const createProvider = (input) => input;\nexport const clampThinkingLevel = () => 'low';" + ), + [`${pi}/dist/api/google-generative-ai.js`]: counted( + "export const stream = () => 'stand-in stream';" + ), + [`${pi}/dist/api/google-shared.js`]: counted( + [ + "export const usesGoogleThinkingLevel = () => false;", + "export const resolveGoogleThinkingLevel = (model, level) => level;", + "export const toGoogleThinkingLevel = (level) => level.toUpperCase();", + "export const toGoogleSdkThinkingLevel = (level) => level;", + ].join("\n") + ), + [`${pi}/dist/api/simple-options.js`]: counted( + "export const buildBaseOptions = () => ({});" + ), + }; + + afterEach(() => { + Reflect.deleteProperty(globalThis, LOADS); + }); + + it("loads pi's level pieces on first use, once, and none without lazyStream or clampThinkingLevel", async () => { + Reflect.set(globalThis, LOADS, 0); + const root = standInInstall({ files: levelFiles }); + const libraries = await loadLibrariesFrom(entryIn(root)); + const source = libraries?.googleLevels; + expect(source).toBeDefined(); + // The stand-in install without the pieces: the Google protocol stays pi's. + const bare = await loadLibrariesFrom(entryIn(standInInstall())); + expect(bare).toBeDefined(); + expect(bare?.googleLevels).toBeUndefined(); + const noClamp = await loadLibrariesFrom( + entryIn( + standInInstall({ + files: { + ...levelFiles, + [`${pi}/dist/models.js`]: esm( + "export const createProvider = (input) => input;" + ), + }, + }) + ) + ); + expect(noClamp?.googleLevels).toBeUndefined(); + // Three libraries loaded — the first's imports long since settled had any + // started — and not one of the three piece modules imported: they load + // on the first call, never with the libraries. + expect(loads()).toBe(0); + const first = source?.load(); + expect(source?.load()).toBe(first); + const pieces = await first; + expect(loads()).toBe(3); + // Once: a later call imports nothing again. + await source?.load(); + expect(loads()).toBe(3); + expect(Object.keys(pieces ?? {}).toSorted()).toEqual([ + "buildBaseOptions", + "clampThinkingLevel", + "resolveGoogleThinkingLevel", + "stream", + "toGoogleSdkThinkingLevel", + "toGoogleThinkingLevel", + "usesGoogleThinkingLevel", + ]); + expect(pieces?.stream({}, {}, {})).toBe("stand-in stream"); + expect(await source?.lazyStream({}, () => Promise.resolve(7))).toBe(7); + }); + + it("answers no pieces when pi lacks one of them", async () => { + const { [`${pi}/dist/api/simple-options.js`]: _gone, ...rest } = levelFiles; + const libraries = await loadLibrariesFrom( + entryIn(standInInstall({ files: rest })) + ); + expect(libraries?.googleLevels).toBeDefined(); + expect(await libraries?.googleLevels?.load()).toBeUndefined(); + }); + + it("from this repository's install, the pieces are pi's own exports", async () => { + const own = await defaultHostModuleSource.libraries({}); + const pieces = await own?.googleLevels?.load(); + const [google, shared, simple, models, lazy] = await Promise.all([ + import("@earendil-works/pi-ai/api/google-generative-ai"), + import("@earendil-works/pi-ai/api/google-shared"), + import("@earendil-works/pi-ai/api/simple-options"), + import("@earendil-works/pi-ai/models"), + import("@earendil-works/pi-ai/api/lazy"), + ]); + expect(own?.googleLevels?.lazyStream).toBe(lazy.lazyStream); + expect(pieces).toEqual({ + buildBaseOptions: simple.buildBaseOptions, + clampThinkingLevel: models.clampThinkingLevel, + resolveGoogleThinkingLevel: shared.resolveGoogleThinkingLevel, + stream: google.stream, + toGoogleSdkThinkingLevel: shared.toGoogleSdkThinkingLevel, + toGoogleThinkingLevel: shared.toGoogleThinkingLevel, + usesGoogleThinkingLevel: shared.usesGoogleThinkingLevel, + }); + }); +}); + +describe("host-modules · the default source", () => { + it("loads pi, dsh-llm and the loader from the wrapper's own tree, once", async () => { + const libraries = await defaultHostModuleSource.libraries(); + expect(libraries).toBeDefined(); + expect(libraries?.version).toBe("1.1.0"); + expect(Object.keys(libraries?.apis ?? {}).toSorted()).toEqual([ + "anthropic-messages", + "google-generative-ai", + "openai-completions", + "openai-responses", + ]); + expect(typeof libraries?.createProvider).toBe("function"); + expect(typeof libraries?.interpolate).toBe("function"); + expect(typeof libraries?.assertUsableApiKey).toBe("function"); + expect(libraries?.LlmError).toBeTypeOf("function"); + expect( + libraries + ?.builtinModels("opencode") + .some((model) => model.id === "deepseek-v4-pro") + ).toBe(true); + expect(await defaultHostModuleSource.libraries()).toBe(libraries); + }); + + it("reads pi-ai's module off its loader entry, unwrapping it through the loader when it must, and nothing else", async () => { + const fiber = { entry: { moduleNamespace: LlmPiAi } }; + expect(await defaultHostModuleSource.piAi({}, fiber)).toBe(LlmPiAi); + const wrapped = { default: LlmPiAi }; + const loader = { + entries: () => [], + unwrapExports: (exports: { default?: unknown }) => exports.default, + }; + expect( + await defaultHostModuleSource.piAi( + { loader }, + { entry: { moduleNamespace: wrapped } } + ) + ).toBe(LlmPiAi); + // A namespace that is not pi-ai's, with no loader to unwrap it and no + // loader entry to resolve from, is no module. + expect( + await defaultHostModuleSource.piAi( + {}, + { entry: { moduleNamespace: wrapped } } + ) + ).toBeUndefined(); + expect(await defaultHostModuleSource.piAi({})).toBeUndefined(); + expect( + await defaultHostModuleSource.piAi({}, { entry: 5 }) + ).toBeUndefined(); + }); + + it("falls back to the module resolved from the loader's base URL when the entry kept no namespace", async () => { + // One loader generation keeps only the fiber on its entry. The module is + // the SAME one the loader executed, reached by resolving the entry's own + // name from the context's base URL — this checkout's root, here, whose + // install is what the loader would have resolved against. + const base = new URL("../", import.meta.url).href; + const entry = { options: { name: "@deepseek-ai/dsh-llm-pi-ai" } }; + const generator = { + *entries() { + yield { options: { name: "something-else" } }; + yield entry; + }, + }; + expect( + await defaultHostModuleSource.piAi({ baseUrl: base, loader: generator }) + ).toBe(LlmPiAi); + // Through `ctx.get`, which is the path that works when `ctx.loader` + // throws for an undeclared service. + const refusing = { + baseUrl: base, + get: (name: string) => (name === "loader" ? generator : undefined), + }; + Object.defineProperty(refusing, "loader", { + get: () => { + throw new Error('cannot get property "loader" without inject'); + }, + }); + expect(await defaultHostModuleSource.piAi(refusing)).toBe(LlmPiAi); + // No entry names pi-ai, no base URL, a base it cannot resolve from, an + // entries() that throws or answers no iterable: each is "no module", + // never a throw. + const only = (entries: unknown) => ({ baseUrl: base, loader: { entries } }); + expect(await defaultHostModuleSource.piAi(only(() => []))).toBeUndefined(); + expect( + await defaultHostModuleSource.piAi({ loader: generator }) + ).toBeUndefined(); + expect( + await defaultHostModuleSource.piAi({ + baseUrl: "file:///no-such-root/", + loader: generator, + }) + ).toBeUndefined(); + expect( + await defaultHostModuleSource.piAi( + only(() => { + throw new Error("entries failed"); + }) + ) + ).toBeUndefined(); + expect(await defaultHostModuleSource.piAi(only(() => 42))).toBeUndefined(); + expect( + await defaultHostModuleSource.piAi({ + baseUrl: base, + get: () => { + throw new Error("no loader"); + }, + }) + ).toBeUndefined(); + expect(await defaultHostModuleSource.piAi(null)).toBeUndefined(); + }); + + it("asPiAiModule answers only a record with pi-ai's three exports", () => { + expect(asPiAiModule(LlmPiAi)).toBe(LlmPiAi); + expect(asPiAiModule({ ...LlmPiAi, recordKeyFor: 1 })).toBeUndefined(); + expect(asPiAiModule(null)).toBeUndefined(); + }); +}); + +describe("cordis-context · the host-module seam", () => { + it("answers both halves, or nothing while either is missing or throws; a replacement restores in order", async () => { + const test = hostModulesForTests(); + const outer = replaceHostModuleSource(test); + try { + const both = await loadHostModules({}); + expect(both?.piAi).toBe(LlmPiAi); + const inner = replaceHostModuleSource({ + libraries: test.libraries, + piAi: async () => {}, + }); + expect(await loadHostModules({})).toBeUndefined(); + inner(); + expect(await loadHostModules({})).toBeDefined(); + const throwing = replaceHostModuleSource({ + libraries: () => Promise.reject(new Error("no")), + piAi: test.piAi, + }); + expect(await loadHostModules({})).toBeUndefined(); + throwing(); + } finally { + outer(); + } + // The default source again: no fiber and no loader entry, so no pi-ai. + expect(await loadHostModules({})).toBeUndefined(); + }); + + it("readLaunchEnvironment reads the composition's snapshot, else the process environment", () => { + const snapshot = { + get: (name: string): unknown => + ({ A: { source: "process", value: "a" }, B: 5 })[name], + }; + const launch = readLaunchEnvironment({ + get: (name: string) => + name === "launchEnvironment" ? snapshot : undefined, + }); + expect(launch.get("A")).toEqual({ value: "a" }); + expect(launch.get("B")).toBeUndefined(); + vi.stubEnv("LAUNCH_ENV_PROBE", "p"); + for (const ctx of [ + {}, + 5, + { + get: () => { + throw new Error("no inject"); + }, + }, + ]) { + const env = readLaunchEnvironment(ctx); + expect(env.get("LAUNCH_ENV_PROBE")).toEqual({ value: "p" }); + expect(env.get("LAUNCH_ENV_PROBE_UNSET")).toBeUndefined(); + } + }); + + it("piAiFibers reads pi-ai's runtimes off the registry, and answers none for anything else", () => { + const fiber = { state: 2, uid: 1 }; + const registry = { + values: () => [ + { fibers: [fiber, 5], name: PI_AI_RUNTIME }, + { fibers: [{ uid: 2 }], name: "other" }, + { name: PI_AI_RUNTIME }, + null, + ], + }; + expect(piAiFibers({ registry })).toEqual([fiber]); + expect(piAiFibers({})).toEqual([]); + expect(piAiFibers(null)).toEqual([]); + expect( + piAiFibers({ + registry: { + values: () => { + throw new Error("gone"); + }, + }, + }) + ).toEqual([]); + }); +}); diff --git a/test/host-planes.test.ts b/test/host-planes.test.ts new file mode 100644 index 00000000..2aa1f0fd --- /dev/null +++ b/test/host-planes.test.ts @@ -0,0 +1,928 @@ +/** + * The plugin owning the plane routes, on the real host (tiers A and B; D13). + * + * The P-R1 rows of the plan's test matrix that the characterization does not + * already carry: who owns the planes and how often pi-ai restarts for it, the + * claim's lifecycle, how an edit flows through it, which routes are + * registered, where a key comes from (D20), and that nothing of a sibling + * 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. + * + * @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, + type ApplyOrder, + bootTierB, + CHARACTERIZED_MODELS, + characterizedProviders, + drainStream, + fakeCredentials, + type HostLlm, + logLines, + mountTierA, + ownerOf, + PLANES, + piAiRestarts, + recordingFetch, + type RecordingFetch, + S0_TODAY, + s0Providers, + SAVE_CANDIDATE, + type TierA, + type TierB, + waitForPlaneOwner, +} from "./support/host.ts"; +import { + createOpencodeStore, + type FixtureStore, +} from "./support/opencode-store-fixture.ts"; +import { + apply, + clearCapturedApiKeys, + Config, + getCapturedApiKey, + inject, + name, + recordCapturedApiKey, + resolveGoApiKey, +} 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(); + } finally { + tierA = undefined; + tierB = undefined; + clearCapturedApiKeys(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + } +}); + +/** 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 }, + () => { + it.each([ + ["pi-ai-first", 1], + ["plugin-first", 0], + ] as const)( + "%s: pi-ai restarts %i time(s), both planes are the plugin's, and no registration is ever refused", + async (order, expected) => { + const { logged, restarts, tier } = await bootPlanes({ order }); + await waitForPlaneOwner(tier.llm); + await settle(); + expect(restarts()).toBe(expected); + expect(routeIds(tier.llm)).toEqual(["deepseek", ...PLANES]); + // 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); + 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.filter((line) => + /still held elsewhere|plane routes did not register|DUPLICATE_ADAPTER/u.test( + line + ) + ) + ).toEqual([]); + } + ); + + 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 + // plane entries themselves are well formed and stay in its raw config. + providers.deepseek = { ...providers.deepseek, api: "nope" }; + const { tier } = await bootPlanes({ order: "pi-ai-first", providers }); + await until( + () => + stateOf(tier.piFiber) === FAILED && + stateOf(tier.pluginEntry?.fiber) === ACTIVE, + "pi-ai fails and the plugin is active" + ); + // The claim reconciles on every registry change and after its mount; + // let every one of them run before reading the registry. + await settle(); + const raw = ( + tier.piFiber as unknown as { _config: { providers: object } } + )._config.providers; + expect(Object.keys(raw)).toEqual(expect.arrayContaining([...PLANES])); + expect( + tier.llm + .listProviders() + .filter((provider) => + (PLANES as readonly string[]).includes(provider.id) + ) + ).toEqual([]); + }); + } +); + +describe("host planes · lifecycle", { timeout: 30_000 }, () => { + 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); + const { pluginEntry } = tier; + if (pluginEntry === undefined) { + throw new Error("no plugin row"); + } + // Disable the row in the profile patch and reload the profile, the way + // a profile edit reaches a running host: the loader disposes its fiber. + const rows = JSON.parse( + readFileSync(tier.profile.patchPath, "utf8") + ) as unknown[]; + writeFileSync( + tier.profile.patchPath, + `${JSON.stringify([...rows, { disabled: true, id: pluginEntry.options.id }], null, 2)}\n` + ); + await reconcileProfilePatches( + tier.ctx as never, + readProfilePatches("dsh", tier.profile), + "dsh" + ); + await until( + () => pluginEntry.fiber === undefined || pluginEntry.fiber.uid === null, + "the plugin row is disposed" + ); + await waitForPlaneOwner(tier.llm, PLANES, "PiAiAdapter"); + expect(routeIds(tier.llm)).toEqual(["deepseek", ...PLANES]); + const before = readFileSync(tier.profile.patchPath); + await expect( + tier.ctx.configEditor.edit( + tier.piEntry, + appendTo("opencode", SAVE_CANDIDATE) + ) + ).rejects.toThrow(S0_TODAY.saveBlockedByStoredGap); + expect(readFileSync(tier.profile.patchPath)).toEqual(before); + }); + + it("dispose, then apply again (tier A): the planes go back to the plugin", async () => { + offline(); + tierA = await mountTierA({ plugin: {}, providers: s0Providers() }); + await waitForPlaneOwner(tierA.llm); + await tierA.pluginFiber?.dispose(); + await waitForPlaneOwner(tierA.llm, PLANES, "PiAiAdapter"); + await tierA.ctx.plugin( + { apply, Config, inject, name } as never, + {} as never + ); + await waitForPlaneOwner(tierA.llm); + expect(routeIds(tierA.llm)).toEqual(["deepseek", ...PLANES]); + }); + + it("an edit of the plugin's own row restarts pi-ai zero times and keeps the planes the plugin's (tier B)", async () => { + const { restarts, tier } = await bootPlanes(); + await waitForPlaneOwner(tier.llm); + const before = restarts(); + const { pluginEntry } = tier; + if (pluginEntry === undefined) { + throw new Error("no plugin row"); + } + await tier.ctx.configEditor.edit(pluginEntry, (current) => ({ + ...current, + debug: true, + })); + await until( + () => (pluginEntry.fiber?.config as { debug?: boolean })?.debug === true, + "the plugin row reloads" + ); + await waitForPlaneOwner(tier.llm); + expect(restarts() - before).toBe(0); + }); + + it("an llm restart registers every route again, the planes the plugin's (tier B)", async () => { + const { tier } = await bootPlanes(); + await waitForPlaneOwner(tier.llm); + const llmEntry = tier.ctx.configEditor + .entries() + .find((entry) => entry.options.id === "llm"); + await llmEntry?.fiber?.restart(); + await until( + () => routeIds(tier.ctx.llm).length === 3, + "every route registers again" + ); + await waitForPlaneOwner(tier.ctx.llm); + expect(routeIds(tier.ctx.llm)).toEqual(["deepseek", ...PLANES]); + }); + + it("pi-ai restarting itself while claimed keeps the planes the plugin's, and throws nothing (tier B)", async () => { + const { tier } = await bootPlanes(); + await waitForPlaneOwner(tier.llm); + await expect(tier.piFiber.restart()).resolves.toBeUndefined(); + await waitForPlaneOwner(tier.llm); + expect(ownerOf(tier.llm, "deepseek")).toBe("PiAiAdapter"); + }); + + it("a turn in flight completes after the plugin is disposed under it (tier A)", async () => { + let release: (() => void) | undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + offline({ + respond: (request) => { + const chunk = (delta: object, finish: string | null) => + `data: ${JSON.stringify({ + choices: [{ delta, finish_reason: finish, index: 0 }], + created: 1, + id: "inflight", + model: request.model, + object: "chat.completion.chunk", + })}\n\n`; + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + controller.enqueue( + encoder.encode(chunk({ content: "hi", role: "assistant" }, null)) + ); + await held; + controller.enqueue(encoder.encode(chunk({}, "stop"))); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(body, { + headers: { "content-type": "text/event-stream" }, + status: 200, + }); + }, + }); + tierA = await mountTierA({ + credentials: fakeCredentials({ refs: { OPENCODE_API_KEY: "k" } }), + plugin: {}, + providers: { + opencode: { + apiKeyEnv: "OPENCODE_API_KEY", + models: [{ id: "deepseek-v4-pro" }], + }, + }, + }); + await waitForPlaneOwner(tierA.llm, ["opencode"]); + const inFlight = drainStream(tierA.llm, "opencode", "deepseek-v4-pro"); + await until(() => wire.requests.length > 0, "the turn is sent"); + await tierA.pluginFiber?.dispose(); + release?.(); + const drained = await inFlight; + expect(drained.error).toBeUndefined(); + expect(drained.finish?.kind).not.toBe("error"); + 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 ?? {} }, + }); + // The plugin's row is ACTIVE only once `apply` returned, and `apply` + // awaits the claim's mount; the mount's own reconcile is queued in its + // `finally`, which settle() lets run. Only then is the empty registry a + // finding rather than a race. + await until( + () => stateOf(tier.pluginEntry?.fiber) === ACTIVE, + "the plugin's row is active" + ); + await settle(); + 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 Go plane with no apiKeyEnv authenticates with pi's OPENCODE_API_KEY lookup, on every protocol; naming OPENCODE_GO_API_KEY sends the Go key", async () => { + // pi 1.1.0's opencode-go provider reads OPENCODE_API_KEY, the same + // variable as Zen (D20): a Go entry without a reference sends the Zen + // key. The two keys differ in length, which is what the recorder keeps. + const refs = { + OPENCODE_API_KEY: "zen-k-from-the-service", + OPENCODE_GO_API_KEY: "go-k", + }; + const sent = async (entry: Record) => { + offline(); + tierA = await mountTierA({ + credentials: fakeCredentials({ refs }), + plugin: {}, + providers: { + "opencode-go": { + models: [{ id: "deepseek-v4-pro" }, { id: "minimax-m3" }], + ...entry, + }, + }, + }); + await waitForPlaneOwner(tierA.llm, ["opencode-go"]); + const completions = await turn( + tierA.llm, + "opencode-go", + "deepseek-v4-pro" + ); + const messages = await turn(tierA.llm, "opencode-go", "minimax-m3"); + await tierA.dispose(); + tierA = undefined; + return [ + completions.requests[0]?.headers.authorization, + messages.requests[0]?.headers["x-api-key"], + ]; + }; + expect(await sent({})).toEqual([ + `Bearer ${refs.OPENCODE_API_KEY}`.length, + refs.OPENCODE_API_KEY.length, + ]); + expect(await sent({ apiKeyEnv: "OPENCODE_GO_API_KEY" })).toEqual([ + `Bearer ${refs.OPENCODE_GO_API_KEY}`.length, + refs.OPENCODE_GO_API_KEY.length, + ]); + }); + + it("the Go meter resolves OPENCODE_GO_API_KEY for a no-reference Go entry until a Go turn, then the Zen key that turn carried", async () => { + // The README's credential-isolation row describes this: the meter never + // reads an OPENCODE_API_KEY reference, but `auto` puts a key captured on + // a Go-plane request ahead of the configured one, so a Go entry that + // names no reference hands the meter the Zen key after its first turn. + const refs = { + OPENCODE_API_KEY: "zen-k-from-the-service", + OPENCODE_GO_API_KEY: "go-k", + }; + offline(); + tierA = await mountTierA({ + credentials: fakeCredentials({ refs }), + plugin: {}, + providers: { "opencode-go": { models: [{ id: "deepseek-v4-pro" }] } }, + }); + await waitForPlaneOwner(tierA.llm, ["opencode-go"]); + expect(await resolveGoApiKey(tierA.ctx, "opencode-go")).toBe( + refs.OPENCODE_GO_API_KEY + ); + const { requests } = await turn( + tierA.llm, + "opencode-go", + "deepseek-v4-pro" + ); + expect(requests[0]?.url).toContain("/inference/go/"); + expect(getCapturedApiKey("opencode-go", "go")).toBe(refs.OPENCODE_API_KEY); + expect(await resolveGoApiKey(tierA.ctx, "opencode-go")).toBe( + refs.OPENCODE_API_KEY + ); + }); + + 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-shapes.test.ts b/test/host-shapes.test.ts index 2f2dc1f1..e1cf4991 100644 --- a/test/host-shapes.test.ts +++ b/test/host-shapes.test.ts @@ -27,11 +27,18 @@ import { adaptersOf, drainStream, fakeCredentials, + hostModulesForTests, mountTierA, recordingFetch, s0Providers, type TierA, } from "./support/host.ts"; +import { + buildPlaneProvider, + OPENCODE_FAMILY_API, + OPENCODE_ZEN_CATALOG, + planeProfileFor, +} from "./support/src.ts"; /** A schemastery node, as far as the role walk reads it. */ interface SchemaNode { @@ -530,3 +537,59 @@ describe("host · pi 1.1.0's public exports (package.json exports ./providers/*, }); }); }); + +describe("host · parity of the plane profile with pi-ai's own resolution", () => { + it("planeProfileFor resolves a catalog-known plane entry's scalar fields exactly as pi-ai resolves them", async () => { + const entry = { + apiKeyEnv: "OPENCODE_API_KEY", + displayName: "Zen", + headers: { "x-a": "1" }, + maxRequestImageBytes: 1024, + models: [{ id: "deepseek-v4-pro", maxTokens: 9 }], + reasoning: "high", + retryPolicy: { maxRetries: 2, mode: "normal" }, + streamIdleTimeoutMs: 1000, + thinkingBudgets: { high: 7 }, + }; + tier = await mountTierA({ + providers: { opencode: structuredClone(entry) }, + }); + const piAi = profileOf(tier, "opencode"); + const libraries = await hostModulesForTests().libraries(); + if (libraries === undefined) { + throw new Error("no libraries"); + } + const { profile } = planeProfileFor({ + buildProvider: buildPlaneProvider(libraries), + catalog: OPENCODE_ZEN_CATALOG, + endpoint: "auto", + entry, + familyApi: OPENCODE_FAMILY_API.zen, + normalize: (raw) => + ( + LlmPiAi.Config({ + providers: { opencode: raw }, + } as never) as unknown as { + providers: { get: () => Record }; + } + ).providers.get().opencode, + piModels: libraries.builtinModels("opencode"), + resolveRetryPolicy: libraries.resolveRetryPolicy, + route: "opencode", + }); + const scalar = (resolved: Record) => { + const { + configuredMaxTokens, + modelErrors, + piProvider: _provider, + ...rest + } = resolved; + return { + ...rest, + configuredMaxTokens: [...(configuredMaxTokens as Map)], + modelErrors: [...(modelErrors as Map)], + }; + }; + expect(scalar(profile)).toEqual(scalar(piAi)); + }); +}); diff --git a/test/host-versions.test.ts b/test/host-versions.test.ts index 81dcf528..1ee2338b 100644 --- a/test/host-versions.test.ts +++ b/test/host-versions.test.ts @@ -56,7 +56,7 @@ const HARNESS_PATCH_HASH = "66eaf960054f10441b93ac3857114e96c1901f6d183713e2f21437b007111644"; describe("host pin", () => { - it("the pi the bridge loads at runtime is the harness's pin", async () => { + it("the pi the plane adapter loads at runtime is the harness's pin", async () => { // `loadHostPi` resolves the wrapper and walks up from its real location to // the first pi it finds — the copy the wrapper itself would load. const pi = await loadHostPi(); @@ -66,7 +66,7 @@ describe("host pin", () => { it("the root pi the tests import is that same copy, and the only one", async () => { // `loadHostPi` hands pi's function through untouched, so identity proves - // one module instance — the tests and the bridge read one file. + // one module instance — the tests and the plane adapter read one file. const pi = await loadHostPi(); expect(pi?.getBuiltinModels).toBe(getBuiltinModels); expect(versionOf(piRoot())).toBe(HOST_VERSIONS.pi); diff --git a/test/keyless-matrix.test.ts b/test/keyless-matrix.test.ts index f616e059..e409fecb 100644 --- a/test/keyless-matrix.test.ts +++ b/test/keyless-matrix.test.ts @@ -17,19 +17,19 @@ import { cellKey, classifyKeylessProbe, isFreeSpec, + isModelGoneMessage, isTransientProbeAnswer, keylessMatrix, MATRIX_PROTOCOLS, type MatrixCell, matrixUrl, - NEXT_DISPATCH, TODAY_DISPATCH, } from "./support/keyless-matrix.ts"; import { type CatalogModelSpec, OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, - protocolFor, + protocolForSdk, } from "./support/src.ts"; const spec = ( @@ -249,7 +249,7 @@ describe("keyless matrix · the cells", () => { cell.plane === "opencode" ? OPENCODE_ZEN_CATALOG : OPENCODE_GO_CATALOG; const first = catalog.find( (entry) => - protocolFor(entry) === cell.protocol && + protocolForSdk(entry.provider_npm) === cell.protocol && entry.is_free !== true && !/free/iu.test(entry.id) ); @@ -303,18 +303,18 @@ describe("keyless matrix · the cells the live run asserts", () => { } } expect([...reached].toSorted()).toEqual([...TODAY_DISPATCH].toSorted()); - // Completions reach the legacy family today: the modern completions cells - // are P-R1's target, asserted live as a forward guard, never as today's. - expect([...TODAY_DISPATCH]).toContain("opencode openai-completions legacy"); + // Every protocol on both planes reaches the modern family, completions + // included (P-R1), and nothing reaches a legacy cell by default. + expect([...TODAY_DISPATCH].toSorted()).toEqual( + PLANES.flatMap((plane) => + MATRIX_PROTOCOLS.map((protocol) => `${plane} ${protocol} modern`) + ).toSorted() + ); expect( - [...NEXT_DISPATCH].filter((cell) => TODAY_DISPATCH.has(cell)) + [...TODAY_DISPATCH].filter((cell) => cell.endsWith("legacy")) ).toEqual([]); - expect([...NEXT_DISPATCH].toSorted()).toEqual([ - "opencode openai-completions modern", - "opencode-go openai-completions modern", - ]); const all = new Set(keylessMatrix({ go: GO, zen: ZEN }).map(key)); - for (const cell of [...TODAY_DISPATCH, ...NEXT_DISPATCH]) { + for (const cell of TODAY_DISPATCH) { expect(all.has(cell), cell).toBe(true); } }); @@ -336,6 +336,21 @@ describe("keyless matrix · reading an answer", () => { }); }); +describe("a live turn's failure message that says the model is gone", () => { + // The adapter blocks of the live e2e suites read this as a reason to ASK: + // a raw probe of the same id decides whether the vendor withdrew it. + it.each([ + ["410 status code (no body)", true], + ["400 Upstream request failed: Model is unavailable", true], + ["model is UNAVAILABLE", true], + ["400 Invalid request: messages is required", false], + ["4100 tokens exceeded", false], + ["", false], + ] as const)("%j → %s", (message, gone) => { + expect(isModelGoneMessage(message)).toBe(gone); + }); +}); + describe("keyless matrix · a transient answer is retried once", () => { it.each([ [undefined, true], diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 91539d30..5a0f86bf 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -14,13 +14,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { type CordisContext, - RESPONSES_ROUTE, apply, clearSessionUsageStore, findModelSpecOn, getSessionUsage, - mountInternalRoutes, - resolveConfig, } from "./support/src.ts"; import { collectUnknown, @@ -36,6 +33,18 @@ afterEach(() => { delete process.env.OPENCODE_SESSION_ID; }); +/** + * 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. + */ +const withScope = (ctx: T): T => + Object.assign(ctx, { + inject: (_deps: string[], callback: (scope: unknown) => void) => { + callback(ctx); + }, + }); + describe("apply (plugin lifecycle)", () => { it("registers model discovery for both OpenCode routes by default", () => { const registered: string[] = []; @@ -49,7 +58,7 @@ describe("apply (plugin lifecycle)", () => { on: () => {}, } as unknown as CordisContext; - apply(ctx); + void apply(withScope(ctx)); // The component's own namespace plus one registration per gateway route, // so DSH's picker can enumerate Go and Zen independently. expect(registered).toContain("dsh-opencode-patch"); @@ -57,54 +66,6 @@ describe("apply (plugin lifecycle)", () => { expect(registered).toContain("opencode"); }); - it("applies modelOverrides and maxContextWindow to discovered models", async () => { - let discoverZen: (() => Promise) | undefined; - const ctx = { - effect: () => {}, - llm: { - registerModelDiscovery: (ns: string, fn: () => Promise) => { - if (ns === "opencode") { - discoverZen = fn; - } - }, - }, - on: () => {}, - } as unknown as CordisContext; - - apply(ctx, { - enrichModels: true, - maxContextWindow: 262_144, - modelOverrides: { - "gpt-5.4": { - contextWindow: 131_072, - inputModalities: ["text"], - maxTokens: 16_384, - name: "GPT-5.4 Lifecycle Override", - }, - }, - }); - - expect(discoverZen).toBeDefined(); - const discovered = (await discoverZen!()) as { - contextWindow: number; - id: string; - inputModalities: string[]; - maxTokens: number; - name: string; - }[]; - const gpt = discovered.find((m) => m.id === "gpt-5.4"); - expect(gpt).toBeDefined(); - expect(gpt?.contextWindow).toBe(131_072); - expect(gpt?.name).toBe("GPT-5.4 Lifecycle Override"); - expect(gpt?.maxTokens).toBe(16_384); - expect(gpt?.inputModalities).toEqual(["text"]); - - // Another model has its context clamped to maxContextWindow (262,144) - const sonnet = discovered.find((m) => m.id === "claude-sonnet-4-5"); - expect(sonnet).toBeDefined(); - expect(sonnet?.contextWindow).toBe(262_144); - }); - 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 @@ -121,10 +82,10 @@ describe("apply (plugin lifecycle)", () => { }, } as unknown as CordisContext; - apply(ctx, { usageEnabled: false }); + void apply(ctx, { usageEnabled: false }); expect(mounted).not.toContain("GoUsageService"); - apply(ctx, { usageEnabled: true }); + void apply(ctx, { usageEnabled: true }); expect(mounted).toContain("GoUsageService"); }); @@ -152,8 +113,8 @@ describe("apply (plugin lifecycle)", () => { // The root IS this context: no `root` property means the composition's own. ctx.root = ctx; - apply(ctx, { usageEnabled: true }); - apply(ctx, { usageEnabled: true }); + void apply(ctx, { usageEnabled: true }); + void apply(ctx, { usageEnabled: true }); expect(mounted.filter((name) => name === "GoUsageService")).toHaveLength(1); }); @@ -186,7 +147,7 @@ describe("apply (plugin lifecycle)", () => { }); // 1. Never registered: `ctx.get` answers nothing for the key. - apply( + void apply( hostWith(() => { /* an unregistered name */ }) as never, @@ -201,7 +162,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; - apply(hostWith(() => ({ read: async () => ({}) })) as never, { + void apply(hostWith(() => ({ read: async () => ({}) })) as never, { usageEnabled: true, }); expect( @@ -220,7 +181,7 @@ describe("apply (plugin lifecycle)", () => { serviceKey: "opencodeGoUsage", }, }; - apply(hostWith(() => bound) as never, { usageEnabled: true }); + void apply(hostWith(() => bound) as never, { usageEnabled: true }); expect( messages.some( (m) => m.includes("reachable") && m.includes("opencodeGoUsage") @@ -228,7 +189,7 @@ describe("apply (plugin lifecycle)", () => { ).toBe(true); }); - it("survives a composition with neither mount path, and reports it", () => { + 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 @@ -243,12 +204,14 @@ describe("apply (plugin lifecycle)", () => { on: () => {}, provide: () => {}, } as unknown as CordisContext; - expect(() => apply(withProvide, { usageEnabled: true })).not.toThrow(); + await expect( + apply(withProvide, { usageEnabled: true }) + ).resolves.toBeUndefined(); // 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. - expect(() => + await expect( apply( { effect: () => {}, @@ -258,7 +221,7 @@ describe("apply (plugin lifecycle)", () => { } as unknown as CordisContext, { usageEnabled: true } ) - ).not.toThrow(); + ).resolves.toBeUndefined(); expect(warned.join(" ")).toContain("neither ctx.plugin nor ctx.provide"); }); @@ -288,16 +251,23 @@ describe("apply (plugin lifecycle)", () => { return rows.find((row) => row.id === id)?.contextWindow; }; - apply(ctx, { maxContextWindow: 262_144 }); - expect(await contextOf("claude-sonnet-4-5")).toBe(262_144); - - apply(ctx, { maxContextWindow: 65_536 }); - expect(await contextOf("claude-sonnet-4-5")).toBe(65_536); + void apply(withScope(ctx), { enrichModels: true }); + // The catalog's own window: the row carries no override of its own (D19). + const catalogWindow = findModelSpecOn( + "zen", + "claude-sonnet-4-5" + )?.context_window; + expect(catalogWindow).toBeGreaterThan(0); + expect(await contextOf("claude-sonnet-4-5")).toBe(catalogWindow); // Turning the feature off on a reload empties the answer rather than // leaving the old catalog published. - apply(ctx, { enrichModels: false }); + void apply(withScope(ctx), { enrichModels: false }); expect(await discover.get("opencode")!()).toEqual([]); + + // And on again: the second reload is answered too, not only the first. + void apply(withScope(ctx), { enrichModels: true }); + expect(await contextOf("claude-sonnet-4-5")).toBeGreaterThan(0); expect([...discover.keys()].toSorted()).toEqual( ["dsh-opencode-patch", "opencode", "opencode-go"].toSorted() ); @@ -321,7 +291,7 @@ describe("apply (plugin lifecycle)", () => { on: () => {}, } as unknown as CordisContext; - apply(ctx); + void apply(withScope(ctx)); expect(registered.toSorted()).toEqual(["opencode", "opencode-go"]); expect(warn).toHaveBeenCalledWith( expect.stringContaining("model discovery for dsh-opencode-patch"), @@ -341,7 +311,7 @@ describe("apply (plugin lifecycle)", () => { on: () => {}, } as unknown as CordisContext; - apply(ctx, { enrichModels: false }); + void apply(withScope(ctx), { enrichModels: false }); expect(registered).toEqual([]); }); @@ -372,7 +342,7 @@ describe("apply (plugin lifecycle)", () => { on: () => {}, }; - apply(ctx); + void apply(ctx); expect(globalThis.fetch).not.toBe(originalFetch); if (typeof disposer === "function") { @@ -390,7 +360,7 @@ describe("apply (plugin lifecycle)", () => { }, on: () => {}, }; - apply(ctx); + void apply(ctx); globalThis.fetch = replacementFetch; if (typeof disposer === "function") { disposer(); @@ -399,305 +369,6 @@ describe("apply (plugin lifecycle)", () => { globalThis.fetch = originalFetch; }); - it("ties the listing patch to the plugin fiber", () => { - // The patch replaces Host methods. Registered OUTSIDE an effect it would - // never be undone, so every live reload would stack another wrapper and - // disabling the plugin would leave it hiding the route. - let cleanup: (() => void) | undefined; - const routes = [ - { id: "opencode", name: "opencode" }, - { id: RESPONSES_ROUTE, name: RESPONSES_ROUTE }, - ]; - const ctx: CordisContext = { - effect: (fn: () => unknown) => { - cleanup = fn() as (() => void) | undefined; - }, - llm: { listProviders: () => routes }, - on: () => {}, - }; - - apply(ctx); - - expect(typeof cleanup).toBe("function"); - expect(ctx.llm?.listProviders?.()).toEqual([routes[0]]); - cleanup?.(); - expect(ctx.llm?.listProviders?.()).toEqual(routes); - }); - - it("says why a hand-named model cannot be served, once", () => { - // The hook is registered through `ctx.on`, not by replacing `llm.stream` - which - // is what a first attempt at this test assumed, and why it asserted against an - // empty list. The gateway answers a Zen free-tier id with a message that reads as - // though the model were broken; measurement says the cause is the model, and none - // of credentials, structure, headers or route. So the hook reports the truth, and - // reports it once, because it runs on every request. - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - const warnings: unknown[][] = []; - const spy = vi - .spyOn(console, "warn") - .mockImplementation((...args: unknown[]) => { - warnings.push(args); - }); - try { - const ctx = { - effect: (fn: () => unknown) => { - fn(); - }, - llm: { - listConfigurableProviders: () => [{ provider: "opencode" }], - listModels: async () => [{ id: "mistral-large-4" }], - listProviders: () => [{ id: "opencode" }], - stream: (options: unknown) => options, - }, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - apply(ctx as never); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } - const call = (): unknown => - streamHandler?.( - { model: "mistral-large-4", provider: "opencode" }, - () => { - // The hook's `next` is never reached for an unservable model. - } - ); - call(); - call(); - const mine = warnings.filter((args) => - String(args[0]).includes("cannot be served:") - ); - expect(mine).toHaveLength(1); - expect(String(mine[0]?.[0])).toContain("mistral-large-4"); - // And it names the reason it COMPUTED. The message used to blame the Zen - // free tier for every unservable id, which was wrong for a Go request and - // wrong again once that exclusion was withdrawn; the only reason left is - // the shape, and the shape is what this now says. - expect(String(mine[0]?.[0])).toContain("@ai-sdk/mistral"); - } finally { - spy.mockRestore(); - } - }); - - it("serves free-tier models and warns only for truly unservable SDKs", () => { - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - const warnings: unknown[][] = []; - const spy = vi - .spyOn(console, "warn") - .mockImplementation((...args: unknown[]) => { - warnings.push(args); - }); - try { - const ctx = { - effect: (fn: () => unknown) => { - fn(); - }, - llm: { - listConfigurableProviders: () => [{ provider: "opencode" }], - listModels: async () => [{ id: "space-bunny-free" }], - listProviders: () => [{ id: "opencode" }], - stream: (options: unknown) => options, - }, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - apply(ctx as never); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } - streamHandler?.( - { model: "space-bunny-free", provider: "opencode" }, - () => {} - ); - expect( - warnings.filter((args) => String(args[0]).includes("space-bunny-free")) - ).toHaveLength(0); - - // And the Go plane's free models are not reported as unservable. - streamHandler?.({ model: "beam-free", provider: "opencode-go" }, () => { - // Served: the Go plane has no free tier to gate. - }); - expect( - warnings.filter((args) => String(args[0]).includes("beam-free")) - ).toHaveLength(0); - } finally { - spy.mockRestore(); - } - }); - - it("hands a responses-format model to the responses route", async () => { - // The gateway serves muse on /responses and everything else on - // /chat/completions, and llm-pi-ai carries one `api` per route. So the call - // is re-dispatched to the route whose `api` already names the format rather - // than translated at the transport — DSH's own adapter then speaks it. - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - const dispatched: unknown[] = []; - let nextCalls = 0; - - const ctx: CordisContext = { - // The redirect asks the hiding patch whether the route is registered, so - // the effect has to actually run for this test to mean anything. - effect: (fn: () => unknown) => { - fn(); - }, - llm: { - listConfigurableProviders: () => [{ provider: "opencode" }], - listModels: async () => [{ id: "muse-spark-1.2" }], - listProviders: () => [{ id: "opencode" }, { id: "opencode-responses" }], - stream: (options: unknown) => { - dispatched.push(options); - return createMockStream("from-responses-route"); - }, - }, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - - apply(ctx); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } - const result: unknown = streamHandler( - { model: "muse-spark-1.2", provider: "opencode" }, - () => { - nextCalls += 1; - return createMockStream("from-opencode-route"); - } - ); - - expect(dispatched).toHaveLength(1); - expect(dispatched[0]).toMatchObject({ - model: "muse-spark-1.2", - provider: "opencode-responses", - }); - // The redirect replaces the dispatch; running both would bill twice. - expect(nextCalls).toBe(0); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } - expect(await collectUnknown(result)).toEqual(["from-responses-route"]); - }); - - it("reads the SDK from the ZEN plane, not a Go-first lookup", async () => { - // The measured mis-route: models.dev's `opencode-go` names - // `@ai-sdk/anthropic` for `qwen3.8-max` while its `opencode` names the - // completions default, and a Go-first lookup therefore sent a ZEN request to - // the anthropic route. The live gateway serves `qwen3.8-max` on - // `/chat/completions` and answers `400 ModelProtocolUnsupported` on - // `/messages`, so the redirect was a hard failure rather than a preference. - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - const dispatched: unknown[] = []; - let nextCalls = 0; - - const ctx: CordisContext = { - effect: (fn: () => unknown) => { - fn(); - }, - llm: { - listConfigurableProviders: () => [{ provider: "opencode" }], - listModels: async () => [{ id: "qwen3.8-max" }], - listProviders: () => [{ id: "opencode" }, { id: "opencode-anthropic" }], - stream: (options: unknown) => { - dispatched.push(options); - return createMockStream("should-not-be-dispatched"); - }, - }, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - - apply(ctx); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } - const result: unknown = streamHandler( - { model: "qwen3.8-max", provider: "opencode" }, - () => { - nextCalls += 1; - return createMockStream("from-opencode-route"); - } - ); - - // The Zen plane declares no SDK for it, so it stays on the route the user - // configured — which is the endpoint that actually serves it. - expect(dispatched).toHaveLength(0); - expect(nextCalls).toBe(1); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } - expect(await collectUnknown(result)).toEqual(["from-opencode-route"]); - }); - - it("dispatches normally when the responses route is not registered", async () => { - // A layer that failed to load must not turn the gateway's own error into a - // "no adapter for provider" one, which points at the wrong thing. - let streamHandler: - | ((options: unknown, next: () => unknown) => unknown) - | undefined; - let nextCalls = 0; - - const ctx: CordisContext = { - effect: (fn: () => unknown) => { - fn(); - }, - llm: { - listModels: async () => [], - // The route really is absent, so the redirect must stand down. - listProviders: () => [{ id: "opencode" }], - stream: () => createMockStream("should-not-be-used"), - }, - on: ( - _event: string, - handler: (options: unknown, next: () => unknown) => unknown - ) => { - streamHandler = handler; - }, - }; - - apply(ctx); - if (typeof streamHandler !== "function") { - throw new TypeError("stream handler not registered"); - } - const result: unknown = streamHandler( - { model: "beam-free", provider: "opencode" }, - () => { - nextCalls += 1; - return createMockStream("from-opencode-route"); - } - ); - - expect(nextCalls).toBe(1); - if (!isAsyncIterableLike(result)) { - throw new Error("expected async iterable downstream"); - } - expect(await collectUnknown(result)).toEqual(["from-opencode-route"]); - }); - it("attaches to llm/stream and derives session ID", async () => { let streamHandler: | ((options: unknown, next: () => unknown) => unknown) @@ -713,7 +384,7 @@ describe("apply (plugin lifecycle)", () => { }, }; - apply(ctx); + void apply(ctx); expect(typeof streamHandler).toBe("function"); if (typeof streamHandler !== "function") { throw new TypeError("stream handler not registered"); @@ -775,7 +446,7 @@ describe("apply (plugin lifecycle)", () => { }, }) as unknown as CordisContext; - apply(ctx); + void apply(ctx); if (typeof streamHandler !== "function") { throw new TypeError("stream handler not registered"); } @@ -808,7 +479,7 @@ describe("apply (plugin lifecycle)", () => { streamHandler = handler; }, }; - apply(ctx); + void apply(ctx); if (typeof streamHandler !== "function") { throw new TypeError("stream handler not registered"); } @@ -871,7 +542,7 @@ describe("apply (plugin lifecycle)", () => { streamHandler = handler; }, }; - apply(ctx); + void apply(ctx); const model = "deepseek-v4-pro"; const go = findModelSpecOn("go", model)?.cost?.input; const zen = findModelSpecOn("zen", model)?.cost?.input; @@ -919,7 +590,7 @@ describe("apply (plugin lifecycle)", () => { streamHandler = handler; }, }; - apply(ctx); + void apply(ctx); if (typeof streamHandler !== "function") { throw new TypeError("stream handler not registered"); } @@ -952,7 +623,7 @@ describe("apply (plugin lifecycle)", () => { throw new Error("on must not be called without fetch"); }, }; - apply(ctx); + void apply(ctx); expect( warnings.some((w) => w.includes("globalThis.fetch is unavailable")) ).toBe(true); @@ -977,7 +648,7 @@ describe("apply (plugin lifecycle)", () => { }, }; - apply(ctx, { debug: true, debugFile }); + void apply(ctx, { debug: true, debugFile }); if (typeof streamHandler !== "function") { throw new TypeError("stream handler not registered"); } @@ -1042,7 +713,7 @@ describe("apply (plugin lifecycle)", () => { on: () => {}, }; try { - apply(ctx, { debug: true, debugFile }); + 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: [] }), @@ -1097,7 +768,7 @@ describe("apply (plugin lifecycle)", () => { streamHandler = handler; }, }; - apply(ctx, { debug: true, debugFile }); + void apply(ctx, { debug: true, debugFile }); if (typeof streamHandler !== "function") { throw new TypeError("stream handler not registered"); } @@ -1143,7 +814,7 @@ describe("apply (plugin lifecycle)", () => { }, }; - apply(ctx); + void apply(ctx); if (typeof streamHandler !== "function") { throw new TypeError("stream handler not registered"); } @@ -1188,7 +859,7 @@ describe("apply (plugin lifecycle)", () => { : undefined, }; - apply(ctx); + void apply(ctx); if (typeof streamHandler !== "function") { throw new TypeError("stream handler not registered"); } @@ -1206,59 +877,3 @@ describe("apply (plugin lifecycle)", () => { expect(await collectUnknown(result)).toEqual(["subagent-chunk"]); }); }); - -describe("mountInternalRoutes", () => { - const config = resolveConfig({}); - const settle = async (): Promise => { - await new Promise((resolve) => { - setImmediate(resolve); - }); - }; - - it("contains a rejected mount instead of leaking an unhandled rejection", async () => { - // A `void` IIFE with no catch turned any bridge failure into an unhandled - // rejection, which Node's default mode answers by exiting the process. - const warn = vi.fn(); - const ctx = { logger: { warn } } as unknown as CordisContext; - const release = mountInternalRoutes(ctx, config, () => - Promise.reject(new Error("DUPLICATE_ADAPTER")) - ); - await settle(); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("internal routes did not mount"), - expect.objectContaining({ message: "DUPLICATE_ADAPTER" }) - ); - expect(() => { - release(); - }).not.toThrow(); - }); - - it("withdraws a mount that settles after its release", async () => { - let resolveMount: (stop: () => void) => void = () => {}; - const stop = vi.fn<() => void>(); - const release = mountInternalRoutes( - {} as CordisContext, - config, - () => - new Promise((resolve) => { - resolveMount = resolve; - }) - ); - release(); - resolveMount(stop); - await settle(); - expect(stop).toHaveBeenCalledTimes(1); - }); - - it("withdraws a settled mount once, however often it is released", async () => { - const stop = vi.fn(); - const release = mountInternalRoutes({} as CordisContext, config, () => - Promise.resolve(stop) - ); - await settle(); - expect(stop).not.toHaveBeenCalled(); - release(); - release(); - expect(stop).toHaveBeenCalledTimes(1); - }); -}); diff --git a/test/live-walk.test.ts b/test/live-walk.test.ts new file mode 100644 index 00000000..33e2ad52 --- /dev/null +++ b/test/live-walk.test.ts @@ -0,0 +1,120 @@ +/** + * The live adapter blocks walk a protocol's candidates rather than dropping + * the protocol on its first withdrawn model. + * + * Offline: the turn is a scripted function, so each verdict path is taken on + * purpose. The case that matters is the one an earlier revision passed: a + * protocol whose only candidate is withdrawn must come back UNREACHED by name, + * never vanish while the others pass. + * + * @module test/live-walk.test + */ +import { describe, expect, it } from "vitest"; + +import { + candidatesByProtocol, + type TurnVerdict, + walkPerProtocol, +} from "./support/live-walk.ts"; + +/** A turn that answers from a script, and records what it was asked. */ +const scripted = (answers: Readonly>) => { + const asked: string[] = []; + const turn = async (protocol: string, id: string): Promise => { + asked.push(`${protocol}/${id}`); + const answer = answers[id]; + if (answer === undefined) { + throw new Error(`unscripted ${id}`); + } + return answer; + }; + return { asked, turn }; +}; + +describe("walking each protocol's candidates", () => { + it("moves past a withdrawn or unentitled model to the next candidate", async () => { + const { asked, turn } = scripted({ + a1: { kind: "gone" }, + a2: { kind: "entitlement" }, + a3: { kind: "ok" }, + a4: { kind: "ok" }, + b1: { kind: "ok" }, + }); + const walk = await walkPerProtocol( + new Map([ + ["a", ["a1", "a2", "a3", "a4"]], + ["b", ["b1"]], + ]), + turn + ); + expect([...walk.reached]).toEqual([ + ["a", "a3"], + ["b", "b1"], + ]); + expect(walk.refused).toEqual([]); + expect(walk.unreached).toEqual([]); + // It stops at the first turn that reached the wire. + expect(asked).toEqual(["a/a1", "a/a2", "a/a3", "b/b1"]); + }); + + it("names a protocol whose every candidate is gone, while the others pass", async () => { + // The case the earlier revision reported green: Go's only Google model + // withdrawn, the other three protocols completing. + const { turn } = scripted({ + argon: { kind: "gone" }, + claude: { kind: "ok" }, + gpt: { kind: "ok" }, + }); + const walk = await walkPerProtocol( + new Map([ + ["anthropic-messages", ["claude"]], + ["google-generative-ai", ["argon"]], + ["openai-responses", ["gpt"]], + ]), + turn + ); + expect(walk.unreached).toEqual(["google-generative-ai"]); + expect(walk.refused).toEqual([]); + expect(walk.reached.has("google-generative-ai")).toBe(false); + }); + + it("records a refusal and stops walking that protocol", async () => { + const { asked, turn } = scripted({ + x1: { detail: "400 bad body", kind: "refused" }, + x2: { kind: "ok" }, + }); + const walk = await walkPerProtocol(new Map([["x", ["x1", "x2"]]]), turn); + expect(walk.refused).toEqual(["x1 (x): 400 bad body"]); + expect(walk.reached.get("x")).toBe("x1"); + expect(walk.unreached).toEqual([]); + // A refusal is about the wire: a later candidate does not overrule it. + expect(asked).toEqual(["x/x1"]); + }); + + it("reports an empty group as unreached", async () => { + const { asked, turn } = scripted({}); + const walk = await walkPerProtocol(new Map([["y", []]]), turn); + expect(walk.unreached).toEqual(["y"]); + expect(asked).toEqual([]); + }); +}); + +describe("grouping candidates by protocol", () => { + it("keeps catalog order, first-seen protocol order, and at most `limit` each", () => { + const rows = [ + { id: "a1", p: "a" }, + { id: "skip", p: undefined }, + { id: "b1", p: "b" }, + { id: "a2", p: "a" }, + { id: "a3", p: "a" }, + { id: "b2", p: "b" }, + ]; + const groups = candidatesByProtocol(rows, (row) => row.p, 2); + expect( + [...groups].map(([protocol, list]) => [protocol, list.map((r) => r.id)]) + ).toEqual([ + ["a", ["a1", "a2"]], + ["b", ["b1", "b2"]], + ]); + }); +}); diff --git a/test/mock/helpers.ts b/test/mock/helpers.ts index b11b3146..d731238c 100644 --- a/test/mock/helpers.ts +++ b/test/mock/helpers.ts @@ -92,7 +92,8 @@ export const FAMILY_ROOTS: Readonly< * **The inference family is not one root per plane.** Its anthropic and google * protocols live at their own paths, declared per model by the vendor's * `GET /console/api/config`; deriving them from the openai root is what sent - * those models to the wrong endpoint. The legacy family genuinely is one root. + * those models to the wrong endpoint. The legacy family genuinely is one root, + * apart from Go's Google protocol, which only the inference family serves. * * @param family - the endpoint family under test. * @param plane - the gateway plane. @@ -104,10 +105,14 @@ export const rootForProtocol = ( plane: TestPlane, protocol: string ): string => { + const go = plane === "opencode-go"; if (family === "legacy") { - return FAMILY_ROOTS.legacy[plane]; + // Go's Google protocol has no endpoint on the legacy family, so its one + // base serves both families (`GO_GOOGLE_BASE_URL`). + return go && protocol === "google-generative-ai" + ? "/inference/go/google/v1beta" + : FAMILY_ROOTS.legacy[plane]; } - const go = plane === "opencode-go"; if (protocol === "anthropic-messages") { return go ? "/inference/go/anthropic/v1" : "/inference/anthropic/v1"; } diff --git a/test/mock/opencode-gateway.ts b/test/mock/opencode-gateway.ts index bcb1fe09..c3339cfc 100644 --- a/test/mock/opencode-gateway.ts +++ b/test/mock/opencode-gateway.ts @@ -517,6 +517,18 @@ const sessionLow12 = (id: string): number | undefined => { return Number.parseInt(hex, 16) % 0x1000; }; +/** + * The Google models measured to think only in levels: each refuses a token + * `thinkingBudget` (on the enabled path or `0` on the disabled one) with 400 + * INVALID_ARGUMENT. Keyed on the MEASURED ids, never on a re-derivation of the + * catalog rule the plugin applies (`usesCatalogThinkingLevels`), so the mock + * does not mirror the rule under test. + */ +const MEASURED_LEVEL_ONLY: ReadonlySet = new Set(["gemini-4-argon"]); + +const THINKING_BUDGET_MESSAGE = + "Thinking budget is not supported for this model. Use thinking level instead."; + export const handleGatewayRequest = ( exchange: GatewayExchange, options: OpenCodeGatewayOptions = {} @@ -771,6 +783,24 @@ export const handleGatewayRequest = ( ); } + // **A level model refuses a budget.** Measured live on Go gemini-4-argon: + // a `generationConfig.thinkingConfig.thinkingBudget` answers 400 + // INVALID_ARGUMENT, whatever its value. + if (endpoint.kind === "google" && MEASURED_LEVEL_ONLY.has(model)) { + const generation = requestBody.generationConfig; + const thinking = isRecord(generation) + ? generation.thinkingConfig + : undefined; + if (isRecord(thinking) && "thinkingBudget" in thinking) { + return refuse( + 400, + THINKING_BUDGET_MESSAGE, + "INVALID_ARGUMENT", + "thinking_budget" + ); + } + } + // **A free-tier call must carry tools.** Measured: the same body with and // without a `tools` array answers 200 and 403 FreeTierError respectively. It is // why `src/tool-fallback.ts` injects read/bash for free ids, and asserting it diff --git a/test/mock/plane-dispatch.mock.ts b/test/mock/plane-dispatch.mock.ts new file mode 100644 index 00000000..b9349410 --- /dev/null +++ b/test/mock/plane-dispatch.mock.ts @@ -0,0 +1,858 @@ +/** + * Every characterized model, on each plane, through the real `LlmRuntime` and + * the plugin's `PlaneAdapter`, to the mock gateway (P-R1). + * + * The host is tier A (`test/support/host.ts`): a real cordis `Context`, the + * real `llm` service, pi-ai, and the plugin mounted beside them, reading the + * host's modules through the seam. `globalThis.fetch` is a rewrite of the + * gateway's origin to the mock's loopback address, and the plugin's own fetch + * patch wraps it, exactly as it wraps the transport in production. So each + * case reads what the GATEWAY received: the path its protocol's SDK built on + * the endpoint family the plane chose, the model, the credential header its + * protocol authenticates with, and the session the turn carried. + * + * The expected protocols and paths are written out here from the vendor facts + * (`docs/redirect-planes.md`), never read from the module under test. + * + * @module test/mock/plane-dispatch.mock + */ + +import { usesGoogleThinkingLevel } from "@earendil-works/pi-ai/api/google-shared"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + CHARACTERIZED_MODELS, + characterizedProviders, + fakeCredentials, + type HostLlm, + logLines, + mountTierA, + PLANES, + type TierA, + waitForPlaneOwner, +} from "../support/host.ts"; +import { + clearCapturedApiKeys, + headerValueFor, + OPENCODE_ZEN_CATALOG, + usesCatalogThinkingLevels, +} from "../support/src.ts"; +import { startMockGateway, type MockGateway } from "./gateway-server.ts"; +import { GO_KEY, ZEN_KEY } from "./helpers.ts"; + +type Plane = (typeof PLANES)[number]; +type Protocol = + | "anthropic-messages" + | "google-generative-ai" + | "openai-completions" + | "openai-responses"; + +/** The protocol each characterized id speaks, per plane, from the catalog's SDK. */ +const PROTOCOL_OF: Readonly>>> = + { + opencode: { + "claude-haiku-5-5": "anthropic-messages", + "deepseek-v4-pro": "openai-completions", + "gemini-3-flash": "google-generative-ai", + "gpt-5.3-codex-spark": "openai-responses", + "gpt-5.4": "openai-responses", + "not-in-any-catalog": "openai-completions", + }, + "opencode-go": { + "deepseek-v4-pro": "openai-completions", + "gemini-4-argon": "google-generative-ai", + "minimax-m3": "anthropic-messages", + "minimax-m3.1-flash": "openai-completions", + "muse-spark-1.2-contributor": "openai-responses", + "not-in-any-catalog": "openai-completions", + }, + }; + +/** The protocol written for an id; a missing row is a broken fixture. */ +const protocolOf = (plane: Plane, model: string): Protocol => { + const protocol = PROTOCOL_OF[plane][model]; + if (protocol === undefined) { + throw new Error(`no protocol written for ${plane}/${model}`); + } + return protocol; +}; + +/** The path one protocol's request to `model` lands on, per plane, in the default (modern) family. */ +const MODERN_PATH = ( + plane: Plane, + protocol: Protocol, + model: string +): string => { + const go = plane === "opencode-go" ? "/go" : ""; + return { + "anthropic-messages": `/inference${go}/anthropic/v1/messages`, + "google-generative-ai": `/inference${go}/google/v1beta/models/${model}:streamGenerateContent`, + "openai-completions": `/inference${go}/openai/v1/chat/completions`, + "openai-responses": `/inference${go}/openai/v1/responses`, + }[protocol]; +}; + +/** The same in the legacy family; Go's Google has only the one base. */ +const LEGACY_PATH = ( + plane: Plane, + protocol: Protocol, + model: string +): string => { + if (plane === "opencode-go" && protocol === "google-generative-ai") { + return MODERN_PATH(plane, protocol, model); + } + const root = plane === "opencode-go" ? "/zen/go/v1" : "/zen/v1"; + return { + "anthropic-messages": `${root}/messages`, + "google-generative-ai": `${root}/models/${model}:streamGenerateContent`, + "openai-completions": `${root}/chat/completions`, + "openai-responses": `${root}/responses`, + }[protocol]; +}; + +/** The header each protocol carries its credential in, and its value. */ +const credentialOf = ( + protocol: Protocol, + key: string +): readonly [string, string] => { + if (protocol === "anthropic-messages") { + return ["x-api-key", key]; + } + if (protocol === "google-generative-ai") { + return ["x-goog-api-key", key]; + } + 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; + +/** + * The fields each protocol's body must carry, written out from the vendor + * formats: Responses an `input`, completions `messages`, Messages `messages` + * and `max_tokens`, Google `contents` (and no `messages`). + */ +const BODY_FIELDS: Readonly> = { + "anthropic-messages": ["max_tokens", "messages", "model"], + "google-generative-ai": ["contents"], + "openai-completions": ["messages", "model"], + "openai-responses": ["input", "model"], +}; + +/** The gateway's answer for an id it does not serve: the vendor's 404. */ +const UNKNOWN_ID = "not-in-any-catalog"; + +/** The finish chunk of one turn's chunks. */ +const finishOf = ( + chunks: readonly Record[] +): { reason?: { kind?: string } } | undefined => + chunks.find((chunk) => chunk.type === "finish") as + | { reason?: { kind?: string } } + | undefined; + +/** + * Assert the gateway accepted one request (or, for the id no catalog names, + * refused it the vendor's way) and that the turn finished accordingly. The + * gateway records a request before it judges it, so the path alone cannot + * tell an accepted body from a refused one. + */ +const expectAnswered = ( + label: string, + model: string, + request: { status: number; reject: string | null } | undefined, + chunks: readonly Record[] +): void => { + const kind = finishOf(chunks)?.reason?.kind; + if (model === UNKNOWN_ID) { + expect({ reject: request?.reject, status: request?.status }, label).toEqual( + { reject: "model_unknown", status: 404 } + ); + expect(kind, label).toBe("error"); + return; + } + expect({ reject: request?.reject, status: request?.status }, label).toEqual({ + reject: null, + status: 200, + }); + expect(kind, label).toBeDefined(); + expect(kind, label).not.toBe("error"); +}; + +/** The DSH session every turn here runs in, and the header it becomes. */ +const SESSION = "plane-dispatch-session"; + +let gateway: MockGateway; +let tier: TierA | undefined; + +beforeAll(async () => { + gateway = await startMockGateway(); +}); + +afterAll(async () => { + await gateway.close(); +}); + +beforeEach(() => { + gateway.requests.length = 0; + clearCapturedApiKeys(); +}); + +afterEach(async () => { + await tier?.dispose(); + tier = undefined; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const realFetch = globalThis.fetch; + +/** Mount tier A with the plugin, the profile and both plane keys. */ +const mount = async ( + providers: Record, + plugin: Record = {} +): Promise => { + vi.spyOn(console, "info").mockImplementation(() => { + // Silenced: the activation line. + }); + vi.stubGlobal("fetch", ((input: RequestInfo | URL, init?: RequestInit) => { + const url = urlOf(input); + if (url.startsWith("https://opencode.ai/")) { + return realFetch( + url.replace("https://opencode.ai", gateway.origin), + init + ); + } + if (url.startsWith(gateway.origin)) { + return realFetch(url, init); + } + return Promise.reject( + new TypeError(`plane-dispatch: refused a request to ${url}`) + ); + }) as typeof fetch); + tier = await mountTierA({ + credentials: fakeCredentials({ + refs: { OPENCODE_API_KEY: ZEN_KEY, OPENCODE_GO_API_KEY: GO_KEY }, + }), + plugin, + providers, + }); + await waitForPlaneOwner( + tier.llm, + PLANES.filter((plane) => plane in providers) + ); + return tier.llm; +}; + +/** One turn through the host's stream path; answers its chunks. */ +const turn = async ( + llm: HostLlm, + provider: string, + model: string, + extra: Record = {} +): Promise[]> => { + const chunks: Record[] = []; + for await (const chunk of llm.stream({ + messages: [{ content: [{ text: "hi", type: "text" }], role: "user" }], + model, + provider, + sessionId: SESSION, + signal: AbortSignal.timeout(10_000), + ...extra, + } as never)) { + chunks.push(chunk as Record); + } + return chunks; +}; + +describe("plane dispatch · the default (modern) family", () => { + it.each(PLANES)( + "%s: every characterized id reaches its protocol's path with its model, the plane's key and the turn's session", + async (plane) => { + const llm = await mount(characterizedProviders()); + for (const model of CHARACTERIZED_MODELS[plane]) { + const protocol = protocolOf(plane, model); + const before = gateway.requests.length; + const chunks = await turn(llm, plane, model); + const sent = gateway.requests.slice(before); + expect( + sent.map((request) => request.path), + `${plane}/${model}` + ).toEqual([MODERN_PATH(plane, protocol, model)]); + const [request] = sent; + expectAnswered(`${plane}/${model}`, model, request, chunks); + const body = (request?.body ?? {}) as Record; + for (const field of BODY_FIELDS[protocol]) { + expect(body[field], `${plane}/${model} body.${field}`).toBeDefined(); + } + if (protocol === "google-generative-ai") { + expect(body.messages, `${plane}/${model}`).toBeUndefined(); + } else { + expect(body.model, `${plane}/${model}`).toBe(model); + } + if (protocol === "anthropic-messages") { + expect(request?.headers["anthropic-version"]).toBeTruthy(); + } + const [header, value] = credentialOf(protocol, keyOf(plane)); + expect(request?.headers[header], `${plane}/${model} ${header}`).toBe( + value + ); + expect(request?.headers["x-opencode-session"]).toBe( + headerValueFor(SESSION) + ); + } + } + ); +}); + +describe("plane dispatch · the legacy family and a route-level base", () => { + it.each(PLANES)( + "%s with endpoint: legacy: the legacy paths, Go's Google on its one base", + async (plane) => { + const llm = await mount(characterizedProviders(), { endpoint: "legacy" }); + for (const model of CHARACTERIZED_MODELS[plane]) { + const protocol = protocolOf(plane, model); + const before = gateway.requests.length; + const chunks = await turn(llm, plane, model); + const sent = gateway.requests.slice(before); + expect( + sent.map((request) => request.path), + `${plane}/${model}` + ).toEqual([LEGACY_PATH(plane, protocol, model)]); + expectAnswered(`${plane}/${model}`, model, sent[0], chunks); + } + } + ); + + it("a route-level vendor base on /zen selects the legacy family under the default endpoint", async () => { + const providers = characterizedProviders(); + providers.opencode!.baseURL = "https://opencode.ai/zen/v1"; + const llm = await mount(providers); + for (const model of ["deepseek-v4-pro", "gpt-5.4", "claude-haiku-5-5"]) { + const protocol = protocolOf("opencode", model); + const before = gateway.requests.length; + const chunks = await turn(llm, "opencode", model); + const sent = gateway.requests.slice(before); + expect(sent.map((request) => request.path)).toEqual([ + LEGACY_PATH("opencode", protocol, model), + ]); + expectAnswered(`opencode/${model}`, model, sent[0], chunks); + } + }); + + it("a foreign route-level base is used as written, Anthropic's without its /v1", async () => { + const llm = await mount({ + opencode: { + apiKeyEnv: "OPENCODE_API_KEY", + baseURL: `${gateway.origin}/proxy/v1`, + models: [{ id: "deepseek-v4-pro" }, { id: "claude-haiku-5-5" }], + }, + }); + for (const [model, path] of [ + ["deepseek-v4-pro", "/proxy/v1/chat/completions"], + ["claude-haiku-5-5", "/proxy/v1/messages"], + ] as const) { + const before = gateway.requests.length; + await turn(llm, "opencode", model); + expect( + gateway.requests.slice(before).map((request) => request.path) + ).toEqual([path]); + } + }); +}); + +describe("plane dispatch · the toggle model's Off and On (D1)", () => { + // One toggle model per protocol the shipped catalog has one for: Anthropic + // on each plane, and completions in a disabling dialect (pi files Zen + // kimi-k2.6 with one). The catalog has no toggle model on Responses or + // Google, so neither is exercised here. The picker's Off is `off` and its + // On is `high` (D1); each body is asserted whole, so an On that sent no + // reasoning, or the wrong field, is red. + it.each([ + ["opencode", "kimi-k2.6", "openai-completions", { type: "enabled" }], + [ + "opencode", + "qwen3.5-plus", + "anthropic-messages", + { budget_tokens: 16_384, display: "summarized", type: "enabled" }, + ], + [ + "opencode-go", + "minimax-m3", + "anthropic-messages", + { budget_tokens: 16_384, display: "summarized", type: "enabled" }, + ], + ] as const)( + "%s %s (%s) sends an explicit disable for Off and an enable for On", + async (plane, model, protocol, on) => { + const llm = await mount({ + [plane]: { + apiKeyEnv: + plane === "opencode" ? "OPENCODE_API_KEY" : "OPENCODE_GO_API_KEY", + models: [{ id: model }], + }, + }); + const info = (await llm.resolveModelInfo(plane, model)) as { + reasoning?: { efforts?: { id: string; name: string }[] }; + }; + expect(info.reasoning?.efforts).toEqual([ + { id: "off", name: "Off" }, + { id: "high", name: "On" }, + ]); + const sent: { path: string; thinking: unknown }[] = []; + for (const reasoningEffort of ["off", "high"]) { + const before = gateway.requests.length; + await turn(llm, plane, model, { reasoningEffort }); + const [request] = gateway.requests.slice(before); + sent.push({ + path: request?.path ?? "", + thinking: (request?.body as { thinking?: unknown } | undefined) + ?.thinking, + }); + } + const path = MODERN_PATH(plane, protocol, model); + expect(sent).toEqual([ + { path, thinking: { type: "disabled" } }, + { path, thinking: on }, + ]); + } + ); +}); + +describe("plane dispatch · a completions model whose effort list names none (D1)", () => { + // pi 1.1.0 files these with `off: "none"`: Off is offered, and the disable + // is `reasoning_effort: "none"`, sent both for Off and for a turn that + // selects no effort (`openai-completions.js:717-721`). The mock gateway + // has no endpoint for `@ai-sdk/mistral` (which endpoint the vendor serves it + // on is unmeasured, docs/redirect-planes.md §2), so it answers Zen + // mistral-large-4 with its protocol refusal. The mock carries no body shape + // check for the completions kind, so that row pins the path, the credential + // header and the `reasoning_effort` read off the parsed body, not a body the + // gateway judged well formed; the Go row, an SDK-less completions model, is + // the case whose body the gateway accepts. + it.each([ + [ + "opencode", + "mistral-large-4", + ["off", "high"], + { reject: "protocol", status: 400 }, + ], + [ + "opencode-go", + "hy3", + ["off", "low", "high"], + { reject: null, status: 200 }, + ], + ] as const)( + "%s %s offers Off and sends reasoning_effort none for Off and for no effort, and the level for High", + async (plane, model, offered, answer) => { + const llm = await mount({ + [plane]: { + apiKeyEnv: + plane === "opencode" ? "OPENCODE_API_KEY" : "OPENCODE_GO_API_KEY", + models: [{ id: model }], + }, + }); + const info = (await llm.resolveModelInfo(plane, model)) as { + reasoning?: { efforts?: { id: string }[] }; + }; + expect(info.reasoning?.efforts?.map((effort) => effort.id)).toEqual( + offered + ); + const sent: { + effort: unknown; + path: string; + reject: string | null; + status: number; + }[] = []; + for (const extra of [ + { reasoningEffort: "off" }, + {}, + { reasoningEffort: "high" }, + ]) { + const before = gateway.requests.length; + await turn(llm, plane, model, extra); + const [request] = gateway.requests.slice(before); + sent.push({ + effort: (request?.body as { reasoning_effort?: unknown } | undefined) + ?.reasoning_effort, + path: request?.path ?? "", + reject: request?.reject ?? null, + status: request?.status ?? 0, + }); + } + const path = MODERN_PATH(plane, "openai-completions", model); + expect(sent).toEqual([ + { effort: "none", path, ...answer }, + { effort: "none", path, ...answer }, + { effort: "high", path, ...answer }, + ]); + } + ); +}); + +describe("plane dispatch · Go Gemini replay stays on one provider label", () => { + it("a Go Gemini turn's replay state is the plane's own, and the next turn replays the signed tool call natively", async () => { + // Driven by its own responder rather than the gateway's canned stream: the + // first answer is a signed tool call, the second plain text. + const wire: { url: string; body: { contents?: { parts?: object[] }[] } }[] = + []; + vi.spyOn(console, "info").mockImplementation(() => { + // Silenced: the activation line. + }); + vi.stubGlobal("fetch", (async ( + input: RequestInfo | URL, + init?: RequestInit + ) => { + const url = urlOf(input); + if (!url.startsWith("https://opencode.ai/")) { + throw new TypeError(`plane-dispatch: refused a request to ${url}`); + } + wire.push({ + body: JSON.parse(typeof init?.body === "string" ? init.body : "{}"), + url, + }); + const parts = + wire.length === 1 + ? [ + { + functionCall: { args: { item: "sample" }, name: "lookup" }, + thoughtSignature: "c2lnbmVkLXRvb2wtZml4dHVyZQ==", + }, + ] + : [{ text: "The result is 42." }]; + return new Response( + `data: ${JSON.stringify({ + candidates: [ + { content: { parts, role: "model" }, finishReason: "STOP" }, + ], + usageMetadata: { + candidatesTokenCount: 3, + promptTokenCount: 10, + totalTokenCount: 13, + }, + })}\n\n`, + { headers: { "content-type": "text/event-stream" } } + ); + }) as typeof fetch); + let log: ReturnType | undefined; + tier = await mountTierA({ + before: (root) => { + log = logLines(root); + }, + credentials: fakeCredentials({ refs: { OPENCODE_GO_API_KEY: GO_KEY } }), + plugin: {}, + providers: { + "opencode-go": { + apiKeyEnv: "OPENCODE_GO_API_KEY", + models: [{ id: "gemini-4-argon" }], + }, + }, + }); + await waitForPlaneOwner(tier.llm, ["opencode-go"]); + const model = "gemini-4-argon"; + const tools = [ + { + description: "Read a sample value", + name: "lookup", + parameters: { + properties: { item: { type: "string" } }, + required: ["item"], + type: "object", + }, + }, + ]; + const messages: unknown[] = [ + { content: [{ text: "Look up sample.", type: "text" }], role: "user" }, + ]; + const first = await turn(tier.llm, "opencode-go", model, { + messages, + tools, + }); + const finish = first.find((chunk) => chunk.type === "finish") as { + reason: { kind: string }; + replayState: { blocks: unknown[]; [key: string]: unknown }; + }; + expect(finish.reason.kind).toBe("tool-calls"); + expect(JSON.stringify(finish.replayState)).toContain('"opencode-go"'); + expect(JSON.stringify(finish.replayState)).not.toContain( + "opencode-go-google" + ); + const blocks = first + .filter((chunk) => chunk.type === "block-end") + .map((chunk) => chunk.block as { type: string; id: string }); + const call = blocks.find((block) => block.type === "tool-call"); + messages.push( + { + content: blocks, + role: "assistant", + source: { + model, + provider: "opencode-go", + replayState: finish.replayState, + }, + }, + { + content: [{ text: "42", type: "text" }], + role: "tool", + toolCallId: call?.id, + } + ); + const second = await turn(tier.llm, "opencode-go", model, { + messages, + tools, + }); + expect( + ( + second.find((chunk) => chunk.type === "finish") as { + reason: { kind: string }; + } + ).reason.kind + ).toBe("stop"); + expect(wire).toHaveLength(2); + for (const request of wire) { + expect(request.url).toBe( + `https://opencode.ai/inference/go/google/v1beta/models/${model}:streamGenerateContent?alt=sse` + ); + } + const replayed = (wire[1]?.body.contents ?? []).flatMap( + (content) => + (content.parts ?? []) as { + thoughtSignature?: string; + functionCall?: object; + }[] + ); + expect( + replayed.some( + (part) => + part.functionCall !== undefined && + part.thoughtSignature === "c2lnbmVkLXRvb2wtZml4dHVyZQ==" + ) + ).toBe(true); + // The replay-degrade warning goes through the host logger: read every + // line it took, and prove the read with a line of our own. + tier.ctx.logger.info("[plane-dispatch] the log is read"); + const lines = log?.lines() ?? []; + expect(lines.at(-1)).toContain("[plane-dispatch] the log is read"); + expect( + lines.filter((line) => line.includes("unusable replay state")) + ).toEqual([]); + log?.dispose(); + }); +}); + +describe("plane dispatch · Google thinking by level, never by budget (D16)", () => { + // Google's discrete `thinkingLevel` and its token `thinkingBudget` are not + // interchangeable: a model that thinks in levels refuses a budget with 400 + // INVALID_ARGUMENT ("Thinking budget is not supported for this model. Use + // thinking level instead."), measured live on Go gemini-4-argon. pi 1.1.0 + // picks the level control only by an id pattern, so the catalog's own + // effort levels are what decide it for an id the pattern misses. + + /** The thinking config one Google request carried. */ + const thinkingConfigOf = (body: unknown): unknown => + (body as { generationConfig?: { thinkingConfig?: unknown } } | undefined) + ?.generationConfig?.thinkingConfig; + + /** One turn's request, as the gateway judged it, and how the turn ended. */ + interface ThinkingTurn { + path: string; + status: number; + thinking: unknown; + reject: string | null; + finish: string | undefined; + } + + /** + * One turn per effort; answers each request's thinking config, the + * gateway's verdict and the turn's finish. `entry` is merged into the + * declared model (a profile's own `reasoningEfforts`). + */ + const thinkingTurns = async ( + plane: Plane, + model: string, + efforts: readonly (string | undefined)[], + entry: Record = {} + ): Promise<{ sent: ThinkingTurn; chunks: Record[] }[]> => { + const llm = await mount({ + [plane]: { + apiKeyEnv: + plane === "opencode" ? "OPENCODE_API_KEY" : "OPENCODE_GO_API_KEY", + models: [{ id: model, ...entry }], + }, + }); + const turns: { sent: ThinkingTurn; chunks: Record[] }[] = + []; + for (const reasoningEffort of efforts) { + const label = `${plane}/${model} ${String(reasoningEffort)}`; + const before = gateway.requests.length; + const chunks = await turn( + llm, + plane, + model, + reasoningEffort === undefined ? {} : { reasoningEffort } + ); + const requests = gateway.requests.slice(before); + expect(requests, label).toHaveLength(1); + const [request] = requests; + turns.push({ + chunks, + sent: { + finish: finishOf(chunks)?.reason?.kind, + path: request?.path ?? "", + reject: request?.reject ?? null, + status: request?.status ?? 0, + thinking: thinkingConfigOf(request?.body), + }, + }); + } + return turns; + }; + + /** + * One turn per effort that the gateway must accept and the turn must + * finish (the live failure was "completes a turn"); answers what each + * request carried. + */ + const thinkingSent = async ( + plane: Plane, + model: string, + efforts: readonly (string | undefined)[] + ): Promise<{ path: string; status: number; thinking: unknown }[]> => { + const turns = await thinkingTurns(plane, model, efforts); + return turns.map(({ chunks, sent }, index) => { + expectAnswered( + `${plane}/${model} ${String(efforts[index])}`, + model, + sent, + chunks + ); + return { path: sent.path, status: sent.status, thinking: sent.thinking }; + }); + }; + + it.each(PLANES)( + "%s gemini-4-argon (catalog effort levels low/medium/high) sends thinkingLevel for a chosen level and for none, and never a thinkingBudget", + async (plane) => { + const model = "gemini-4-argon"; + const sent = await thinkingSent(plane, model, ["high", "low", undefined]); + const path = MODERN_PATH(plane, "google-generative-ai", model); + expect(sent).toEqual([ + { + path, + status: 200, + thinking: { includeThoughts: true, thinkingLevel: "HIGH" }, + }, + { + path, + status: 200, + thinking: { includeThoughts: true, thinkingLevel: "LOW" }, + }, + // No effort selected: the level pi itself falls back to for a level + // model (its lowest offered), not `thinkingBudget: 0`. + { path, status: 200, thinking: { thinkingLevel: "LOW" } }, + ]); + for (const { thinking } of sent) { + expect(JSON.stringify(thinking)).not.toContain("thinkingBudget"); + } + } + ); + + it("a Gemini 3 id pi already serves by level keeps pi's own request, for a chosen level and for none", async () => { + // Picked from the shipped Zen catalog by shape: a Google model the catalog + // describes by level (so the wrapper takes it) whose id pi's own pattern + // (`usesGoogleThinkingLevel`, asked of the installed pi) also serves by + // level (so the wrapper hands it back). The case runs that hand-back over + // the real adapter and pi's real `lazyStream`; which path answered is + // pinned by test/google-levels.test.ts, and this case pins that the wire + // is pi's. + const control = OPENCODE_ZEN_CATALOG.find( + (spec) => + spec.provider_npm === "@ai-sdk/google" && + spec.reasoning === true && + usesCatalogThinkingLevels(spec) && + usesGoogleThinkingLevel({ id: spec.id } as never) + ); + expect( + control, + "the Zen catalog has a Gemini model the wrapper takes and pi serves by level" + ).toBeDefined(); + const values = + control?.reasoning_options?.find((option) => option.type === "effort") + ?.values ?? []; + const lowest = ["minimal", "low", "medium", "high"].find((level) => + values.includes(level) + ); + expect(lowest, `${control?.id} offers a level`).toBeDefined(); + const sent = await thinkingSent("opencode", control?.id ?? "", [ + "high", + undefined, + ]); + const path = MODERN_PATH( + "opencode", + "google-generative-ai", + control?.id ?? "" + ); + expect(sent).toEqual([ + { + path, + status: 200, + thinking: { includeThoughts: true, thinkingLevel: "HIGH" }, + }, + { + path, + status: 200, + thinking: { thinkingLevel: lowest?.toUpperCase() }, + }, + ]); + }); + + it("a profile that declares Off for gemini-4-argon gets pi's own disable, thinkingBudget: 0, which the model refuses (a profile error)", async () => { + // The catalog never offers Off on Google; a profile that declares one + // makes Off supported, and pi disables a level model with a budget of 0 + // for an id its pattern misses, as it does for its own level ids. Pinned + // so a later decision to serve a declared Off differently flips this row. + const model = "gemini-4-argon"; + const walked = await thinkingTurns( + "opencode-go", + model, + [undefined, "high"], + { reasoningEfforts: { high: "high", low: "low", off: null } } + ); + const turns = walked.map(({ sent }) => sent); + const path = MODERN_PATH("opencode-go", "google-generative-ai", model); + expect(turns).toEqual([ + { + finish: "error", + path, + reject: "thinking_budget", + status: 400, + thinking: { thinkingBudget: 0 }, + }, + { + finish: expect.any(String), + path, + reject: null, + status: 200, + thinking: { includeThoughts: true, thinkingLevel: "HIGH" }, + }, + ]); + expect(turns[1]?.finish).not.toBe("error"); + }); +}); diff --git a/test/mock/protocol-compat.mock.ts b/test/mock/protocol-compat.mock.ts index 6880d3fb..c0a6d1b2 100644 --- a/test/mock/protocol-compat.mock.ts +++ b/test/mock/protocol-compat.mock.ts @@ -5,7 +5,7 @@ * (Zen, Go) crossed with every protocol it speaks (Responses, Completions, * Messages, Google) crossed with both endpoint families (`auto`, the * catalog's address; `legacy`, the older one still entitled separately). The - * request is built exactly as production builds it — `piModelFor` for the + * request is built exactly as production builds it — `planeModelFor` for the * model, pi's own per-protocol API factory for the stream — and travels the * full path: `patchFetch` claims it, shapes it, and the mock gateway either * accepts it and streams back, or refuses with the reason. @@ -35,7 +35,8 @@ import { OPENCODE_UA, SESSION_HEADER, clearCapturedApiKeys, - piModelFor, + OPENCODE_FAMILY_API, + planeModelFor, resolveConfig, } from "../support/src.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; @@ -114,7 +115,25 @@ describe("plane × protocol × family", () => { const text = await harness.als.run( sessionTurn({ model: spec.id, provider: plane }), async () => { - const model = piModelFor(plane, spec, protocol, undefined, family); + const { model, error } = planeModelFor({ + defaults: { + contextWindow: 262_144, + input: ["text"], + maxTokens: 32_768, + }, + endpoint: family, + entry: { id: spec.id }, + familyApi: + plane === "opencode" + ? OPENCODE_FAMILY_API.zen + : OPENCODE_FAMILY_API.go, + route: plane, + spec, + }); + if (model === undefined) { + throw new Error(`${plane}/${spec.id}: ${error}`); + } + expect(model.api).toBe(protocol); return collectText(openStream(api, model, keyFor(plane))); } ); diff --git a/test/model-metadata.test.ts b/test/model-metadata.test.ts new file mode 100644 index 00000000..9ce786ed --- /dev/null +++ b/test/model-metadata.test.ts @@ -0,0 +1,405 @@ +/** + * One model's reasoning levels, in pi's map, a profile's dict and the picker's + * efforts at once (`src/utils/model-metadata.ts`, D1). + * + * The rule the picker shows and the rule dispatch validates must be one rule, + * so the decisive case runs pi's OWN `getSupportedThinkingLevels` over the map + * for every bundled catalog model and compares it with the efforts offered. + * + * @module test/model-metadata.test + */ +import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; + +import { + canDisableReasoning, + type CatalogModelSpec, + effortName, + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, + PI_LEVELS, + piReasoningFromEfforts, + protocolForSdk, + reasoningFor, + usesCatalogThinkingLevels, +} from "./support/src.ts"; + +const zen = (id: string): CatalogModelSpec => { + const spec = OPENCODE_ZEN_CATALOG.find((model) => model.id === id); + if (spec === undefined) { + throw new Error(`no Zen spec ${id}`); + } + return spec; +}; + +const go = (id: string): CatalogModelSpec => { + const spec = OPENCODE_GO_CATALOG.find((model) => model.id === id); + if (spec === undefined) { + throw new Error(`no Go spec ${id}`); + } + return spec; +}; + +const ids = (reasoning: ReturnType): string[] => + reasoning?.efforts.map((effort) => effort.id) ?? []; + +describe("model-metadata · reasoningFor (D1)", () => { + it("offers a toggle-only model Off/On where its protocol can disable reasoning, in both vocabularies", () => { + expect(go("minimax-m3").reasoning_options).toEqual([{ type: "toggle" }]); + const reasoning = reasoningFor(go("minimax-m3"), "anthropic-messages"); + expect(reasoning?.efforts).toEqual([ + { id: "off", name: "Off" }, + { id: "high", name: "On" }, + ]); + expect(reasoning?.thinkingLevelMap).toEqual({ + high: "high", + low: null, + max: null, + medium: null, + minimal: null, + xhigh: null, + }); + expect("off" in (reasoning?.thinkingLevelMap ?? {})).toBe(false); + expect(reasoning?.reasoningEfforts).toEqual({ high: "high", off: null }); + expect(reasoning?.effortNames).toEqual({ high: "On", off: "Off" }); + }); + + it("offers On alone on completions with no disabling dialect, and Off/On with one", () => { + const plain = reasoningFor(zen("minimax-m3"), "openai-completions"); + expect(plain?.efforts).toEqual([{ id: "high", name: "On" }]); + expect(plain?.thinkingLevelMap.off).toBeNull(); + expect(plain?.reasoningEfforts).toEqual({ high: "high" }); + const deepseek = reasoningFor(zen("minimax-m3"), "openai-completions", { + thinkingFormat: "deepseek", + }); + expect(ids(deepseek)).toEqual(["off", "high"]); + }); + + it("offers Off for a none in a completions effort list, with none as Off's wire value in both vocabularies, as pi's own entry does", () => { + expect(zen("mistral-large-4").reasoning_options).toEqual([ + { type: "effort", values: ["none", "high"] }, + ]); + const mistral = reasoningFor(zen("mistral-large-4"), "openai-completions"); + expect(mistral?.efforts).toEqual([ + { id: "off", name: "Off" }, + { id: "high", name: "High" }, + ]); + expect(mistral?.thinkingLevelMap).toEqual({ + high: "high", + low: null, + max: null, + medium: null, + minimal: null, + off: "none", + xhigh: null, + }); + expect(mistral?.reasoningEfforts).toEqual({ high: "high", off: "none" }); + // The profile dict reads back to the same map through pi-ai's own rule. + expect( + piReasoningFromEfforts( + "mistral-large-4", + "opencode", + mistral?.reasoningEfforts ?? false + ) + ).toEqual({ reasoning: true, thinkingLevelMap: mistral?.thinkingLevelMap }); + // Go's completions models that list none read the same. + expect(ids(reasoningFor(go("hy3"), "openai-completions"))).toEqual([ + "off", + "low", + "high", + ]); + expect( + reasoningFor(go("hy4-preview"), "openai-completions")?.thinkingLevelMap + .off + ).toBe("none"); + // An "off" value is the same signal, carried verbatim. + expect( + reasoningFor( + { + reasoning: true, + reasoning_options: [{ type: "effort", values: ["off", "low"] }], + }, + "openai-completions" + )?.reasoningEfforts + ).toEqual({ low: "low", off: "off" }); + }); + + it("offers an effort list exactly, no Off for a none on Responses, and low/medium/high for a bare reasoning flag", () => { + const gpt = reasoningFor(zen("gpt-5.4"), "openai-responses"); + expect(zen("gpt-5.4").reasoning_options?.[0]?.values).toContain("none"); + expect(ids(gpt)).toEqual(["low", "medium", "high", "xhigh"]); + expect(gpt?.thinkingLevelMap.off).toBeNull(); + expect(gpt?.efforts[0]?.name).toBe("Low"); + expect( + ids(reasoningFor(zen("deepseek-v4-pro"), "openai-completions")) + ).toEqual(["high", "max"]); + expect( + ids(reasoningFor(zen("minimax-m2.5"), "openai-completions")) + ).toEqual(["low", "medium", "high"]); + expect( + reasoningFor({ reasoning: false }, "openai-completions") + ).toBeUndefined(); + expect(reasoningFor({}, "openai-completions")).toBeUndefined(); + // An effort option with no values is no list. + expect( + ids( + reasoningFor( + { + reasoning: true, + reasoning_options: [{ type: "effort" }, { type: "toggle" }], + }, + "openai-responses" + ) + ) + ).toEqual(["off", "high"]); + // An effort list naming no pi level falls through to the default. + expect( + ids( + reasoningFor( + { + reasoning: true, + reasoning_options: [{ type: "effort", values: ["none"] }], + }, + "openai-responses" + ) + ) + ).toEqual(["low", "medium", "high"]); + }); + + it("is one rule for picker and dispatch: pi's own getSupportedThinkingLevels over the map equals the efforts, for every bundled catalog model on both planes", () => { + let compared = 0; + for (const catalog of [OPENCODE_ZEN_CATALOG, OPENCODE_GO_CATALOG]) { + for (const spec of catalog) { + const protocol = protocolForSdk(spec.provider_npm); + expect(protocol, spec.id).toBeDefined(); + const reasoning = reasoningFor(spec, protocol ?? "openai-completions"); + if (reasoning === undefined) { + continue; + } + expect( + getSupportedThinkingLevels({ + reasoning: true, + thinkingLevelMap: reasoning.thinkingLevelMap, + } as never), + spec.id + ).toEqual(ids(reasoning)); + compared += 1; + } + } + expect(compared).toBeGreaterThan(50); + }); +}); + +describe("model-metadata · canDisableReasoning and effortName", () => { + it("answers per protocol, completions only in the disabling dialects", () => { + expect(canDisableReasoning("anthropic-messages")).toBe(true); + expect(canDisableReasoning("openai-responses")).toBe(true); + expect(canDisableReasoning("google-generative-ai")).toBe(true); + expect(canDisableReasoning("openai-completions")).toBe(false); + for (const format of [ + "deepseek", + "openrouter", + "string-thinking", + "together", + ]) { + expect( + canDisableReasoning("openai-completions", { thinkingFormat: format }) + ).toBe(true); + } + expect( + canDisableReasoning("openai-completions", { thinkingFormat: "qwen" }) + ).toBe(false); + expect( + canDisableReasoning("openai-completions", { thinkingFormat: 1 }) + ).toBe(false); + expect(PI_LEVELS.map(effortName)).toEqual([ + "Off", + "Minimal", + "Low", + "Medium", + "High", + "Xhigh", + "Max", + ]); + }); +}); + +describe("model-metadata · piReasoningFromEfforts (pi-ai's resolveModelReasoning)", () => { + it("translates a dict with every level decided, and refuses what pi-ai refuses in its words", () => { + expect(piReasoningFromEfforts("m", "opencode", false)).toEqual({ + reasoning: false, + }); + expect( + piReasoningFromEfforts("m", "opencode", { high: "hi", off: null }) + ).toEqual({ + reasoning: true, + thinkingLevelMap: { + high: "hi", + low: null, + max: null, + medium: null, + minimal: null, + xhigh: null, + }, + }); + expect( + piReasoningFromEfforts("m", "opencode", { high: "hi", off: "none" }) + ).toMatchObject({ thinkingLevelMap: { off: "none" } }); + const error = (dict: Record | null): string => + (piReasoningFromEfforts("m", "opencode", dict) as { error: string }) + .error; + expect(error({ low: null })).toMatch( + /reasoningEfforts\.low needs the wire value/u + ); + expect(error({})).toMatch(/has an empty reasoningEfforts/u); + expect(error(null)).toMatch(/has an empty reasoningEfforts/u); + expect(error({ low: "" })).toMatch( + /reasoningEfforts\.low must not be an empty string/u + ); + expect(error({ off: null })).toMatch(/offers no level beyond "off"/u); + expect(error({ low: null })).toMatch( + /^llm-pi-ai: provider "opencode" model "m" /u + ); + }); +}); + +describe("model-metadata · usesCatalogThinkingLevels (D16: Google's level control, from the catalog)", () => { + const google = "@ai-sdk/google"; + const effort = (values: readonly string[]) => [{ type: "effort", values }]; + + it("a Google model whose catalog efforts are all discrete levels thinks in levels", () => { + // The live case: pi 1.1.0's id pattern misses it, the catalog does not. + expect(usesCatalogThinkingLevels(go("gemini-4-argon"))).toBe(true); + expect(usesCatalogThinkingLevels(zen("gemini-3-flash"))).toBe(true); + for (const values of [ + ["low", "medium", "high"], + ["minimal", "low", "medium", "high"], + ["high"], + ["minimal"], + ]) { + expect( + usesCatalogThinkingLevels({ + provider_npm: google, + reasoning: true, + reasoning_options: effort(values), + }), + values.join(",") + ).toBe(true); + } + }); + + it("a budget, a switch or any other effort shape does not", () => { + for (const reasoning_options of [ + // A value outside Google's four levels. + effort(["low", "high", "max"]), + effort(["low", "medium", "high", "xhigh"]), + effort(["none", "low", "high"]), + effort(["off", "high"]), + // No list at all, an empty one, or a switch. + effort([]), + [{ type: "effort" }], + [{ type: "toggle" }], + // A non-effort option holding level-named values, and the catalog's + // own budget shapes on their own. + [{ type: "budget", values: ["low"] }], + [{ type: "budget_tokens" }], + [{ min: 1024, type: "budget_tokens" }], + [], + undefined, + ]) { + expect( + usesCatalogThinkingLevels({ + provider_npm: google, + reasoning: true, + ...(reasoning_options === undefined ? {} : { reasoning_options }), + }), + JSON.stringify(reasoning_options) + ).toBe(false); + } + // The first effort list with values is the one read, as reasoningFor reads it. + expect( + usesCatalogThinkingLevels({ + provider_npm: google, + reasoning: true, + reasoning_options: [ + { type: "effort", values: [] }, + { type: "effort", values: ["low", "xhigh"] }, + { type: "effort", values: ["low"] }, + ], + }) + ).toBe(false); + }); + + it("a budget_tokens option beside an all-levels effort list does not change the answer", () => { + // The effort list decides: the catalog lists the levels as accepted, and + // a budget as accepted too, so the level request stays valid. The budget + // option is the catalog's own shape (`min` beside the type). + const budgetTokens = { min: 1024, type: "budget_tokens" }; + expect( + usesCatalogThinkingLevels({ + provider_npm: google, + reasoning: true, + reasoning_options: [...effort(["low", "high"]), budgetTokens], + }) + ).toBe(true); + expect( + usesCatalogThinkingLevels({ + provider_npm: google, + reasoning: true, + reasoning_options: [ + { type: "budget_tokens" }, + ...effort(["low", "xhigh"]), + ], + }) + ).toBe(false); + }); + + it("a model that does not reason, or is not served on Google, does not", () => { + for (const reasoning of [false, undefined]) { + expect( + usesCatalogThinkingLevels({ + provider_npm: google, + ...(reasoning === undefined ? {} : { reasoning }), + reasoning_options: effort(["low", "high"]), + }) + ).toBe(false); + } + for (const provider_npm of [ + "@ai-sdk/anthropic", + "@ai-sdk/openai", + "@ai-sdk/mistral", + "@ai-sdk/unknown", + undefined, + ]) { + expect( + usesCatalogThinkingLevels({ + ...(provider_npm === undefined ? {} : { provider_npm }), + reasoning: true, + reasoning_options: effort(["low", "medium", "high"]), + }), + String(provider_npm) + ).toBe(false); + } + }); + + it("over the bundled catalogs, every Google reasoning model and nothing else (today's catalogs)", () => { + // An oracle that does not re-derive the rule: every Google reasoning entry + // the vendor lists today offers only discrete levels. A future Google + // entry with a budget or an `xhigh` flips this row on purpose. + const goLevelIds: string[] = []; + let seen = 0; + for (const spec of [...OPENCODE_GO_CATALOG, ...OPENCODE_ZEN_CATALOG]) { + const expected = spec.provider_npm === google && spec.reasoning === true; + seen += expected ? 1 : 0; + expect(usesCatalogThinkingLevels(spec), spec.id).toBe(expected); + } + for (const spec of OPENCODE_GO_CATALOG) { + if (usesCatalogThinkingLevels(spec)) { + goLevelIds.push(spec.id); + } + } + // The read is real: the catalogs hold the live case and more. + expect(seen).toBeGreaterThan(1); + expect(goLevelIds).toContain("gemini-4-argon"); + }); +}); diff --git a/test/models-discovery.test.ts b/test/models-discovery.test.ts index fe300fe0..e177a73a 100644 --- a/test/models-discovery.test.ts +++ b/test/models-discovery.test.ts @@ -6,31 +6,24 @@ * runs, so decorating the discovery answer is the only way to add a missing * catalog row or remove a provider-retired row from “fetch available models.” * + * The listing, model-info and plane-adapter decorations this file used to + * cover are gone (P-R1): the plane adapter answers those itself, and + * `test/host-characterization.test.ts` and `test/host-planes.test.ts` pin + * what it answers on the real host. + * * @module test/models-discovery.test */ import { afterEach, describe, expect, it, vi } from "vitest"; import { - baseForProtocol, type CordisContext, - PI_THINKING_LEVELS, - RESPONSES_ROUTE, decorateModelDiscovery, - decorateModelInfo, - decoratePlaneAdapters, - findModelSpecOn, - hideResponsesRoute, isDeprecatedModelOn, - isRouteRegistered, - isServableModel, mergeDiscoveredModels, parseModelsDevCatalog, - protocolFor, resolveConfig, resolveDiscoveryProvider, - resolveEffectiveContextWindow, - thinkingLevelMapFor, } from "./support/src.ts"; afterEach(() => { @@ -360,1560 +353,3 @@ describe("decorateModelDiscovery", () => { expect(llm.discoverModels).toBe(foreign); }); }); - -describe("models-discovery · an empty list from a route that declares models", () => { - it("falls back to the declaration, so the picker is not emptied by a missing key", async () => { - // `dsh-llm-pi-ai` resolves the route's credential before listing and answers - // [] when it cannot — at boot, the ordinary case. Measured consequence: the - // composer offered none of that route's models at all, so there was no - // OpenCode model to select and the usage meter (gated to OpenCode) could - // never appear. - const ctx = { - llm: { - listProviders: () => [{ id: "opencode" }], - listConfigurableProviders: () => [], - listModels: async () => [], - }, - loader: { - *entries() { - yield { - options: { - config: { - providers: { - opencode: { models: [{ id: "gpt-5.4", name: "GPT-5.4" }] }, - }, - }, - }, - }; - }, - }, - } as unknown as CordisContext; - const stop = hideResponsesRoute(ctx, resolveConfig()); - const listed = (await ctx.llm?.listModels?.("opencode")) as Array<{ - id: string; - name: string; - provider: string; - }>; - expect(listed).toEqual([ - { id: "gpt-5.4", name: "GPT-5.4", provider: "opencode" }, - ]); - stop?.(); - }); - - it("passes a provider it does not claim through untouched", async () => { - // A direct `anthropic` or `openai` route is the deployment's, not this - // plugin's. Its ids are not in the OpenCode catalog's routing, and the - // servability filter used to drop them as "unservable". - const foreign = [ - { id: "claude-opus-9", name: "Claude Opus 9", provider: "anthropic" }, - { id: "gpt-7", name: "GPT-7", provider: "openai" }, - ]; - const ctx = { - llm: { - listProviders: () => [], - listConfigurableProviders: () => [], - listModels: async (provider: string) => - foreign.filter((row) => row.provider === provider), - }, - } as unknown as CordisContext; - const stop = hideResponsesRoute(ctx, resolveConfig()); - expect(await ctx.llm?.listModels?.("anthropic")).toEqual([foreign[0]]); - expect(await ctx.llm?.listModels?.("openai")).toEqual([foreign[1]]); - stop?.(); - }); - - it("leaves an adapter's own answer alone", async () => { - const ctx = { - llm: { - listProviders: () => [], - listConfigurableProviders: () => [], - listModels: async () => [ - { id: "adapter-row", name: "Adapter Row", provider: "opencode" }, - ], - }, - loader: { - entries: () => [ - { - options: { - config: { - providers: { opencode: { models: [{ id: "declared" }] } }, - }, - }, - }, - ], - }, - } as unknown as CordisContext; - const stop = hideResponsesRoute(ctx, resolveConfig()); - const listed = (await ctx.llm?.listModels?.("opencode")) as Array<{ - id: string; - }>; - expect(listed.map((m) => m.id)).toEqual(["adapter-row"]); - stop?.(); - }); -}); - -describe("models-discovery: hiding the internal Responses route", () => { - const hostWith = () => { - const seen: string[] = []; - const ctx = { - llm: { - listConfigurableProviders: () => [ - { provider: "opencode" }, - { provider: RESPONSES_ROUTE }, - ], - listModels: async (provider: string) => { - seen.push(provider); - return [{ id: `${provider}-model` }]; - }, - listProviders: () => [ - { id: "opencode", name: "opencode" }, - { id: RESPONSES_ROUTE, name: RESPONSES_ROUTE }, - ], - }, - } as unknown as CordisContext; - return { ctx, seen }; - }; - - it("removes the route from every listing a user sees", () => { - // Three surfaces enumerate providers and there is no hidden flag on any of - // them. `joinProviderDirectory` (Settings → Models) pushes a row for every - // REGISTERED provider, so filtering only the configurable directory would - // leave the route on screen — `listProviders` is the one that matters. - const { ctx } = hostWith(); - const stop = hideResponsesRoute(ctx, resolveConfig()); - expect(stop).toBeDefined(); - - expect(ctx.llm?.listProviders?.()).toEqual([ - { id: "opencode", name: "opencode" }, - ]); - expect(ctx.llm?.listConfigurableProviders?.()).toEqual([ - { provider: "opencode" }, - ]); - }); - - it("still reports the route as registered, so the redirect fires", () => { - // The redirect asks whether it has somewhere to go. Asked of the FILTERED - // listing the answer would always be no and the re-dispatch would never - // happen — which is why the check reads the original. - const { ctx } = hostWith(); - const stop = hideResponsesRoute(ctx, resolveConfig()); - - expect(isRouteRegistered(RESPONSES_ROUTE)).toBe(true); - expect(ctx.llm?.listProviders?.()).toHaveLength(1); - - stop?.(); - expect(isRouteRegistered(RESPONSES_ROUTE)).toBe(true); - }); - - it("keeps servable models and empties the internal routes", async () => { - // Filtering `listModels` is not about hiding the internal routes — nothing - // reaches it for those. It is about what a user CAN pick: a model whose SDK - // no route serves must not reach a listing they choose from. A model the - // catalog does not know names no SDK, which is the route's own api. - const { ctx, seen } = hostWith(); - const stop = hideResponsesRoute(ctx, resolveConfig()); - - await expect(ctx.llm?.listModels?.(RESPONSES_ROUTE)).resolves.toEqual([]); - // **Exactly the adapter's list — nothing added.** The route's `models:` array - // is the operator's decision, and a revision that merged the plane's catalog - // in here silently widened every explicit configuration. That join is gone - // and this is the guard against it coming back: one declared row in, one row - // out, even though the catalog knows hundreds more. - await expect(ctx.llm?.listModels?.("opencode")).resolves.toEqual([ - { id: "opencode-model" }, - ]); - expect(seen).toEqual(["opencode"]); - - stop?.(); - expect(ctx.llm?.listProviders?.()).toHaveLength(2); - }); - - it("never widens a configured list, even when the catalog knows more", async () => { - // The operator's `models:` array is the whole list. A model the catalog - // knows, that the plane could serve, and that the operator did NOT declare - // must stay out of the picker — otherwise the plugin answers a question the - // deployment already answered. - const { ctx } = hostWith(); - const stop = hideResponsesRoute(ctx, resolveConfig()); - - const listed = (await ctx.llm?.listModels?.("opencode")) as Array<{ - id: string; - }>; - const ids = new Set(listed.map((m) => m.id)); - expect(listed).toHaveLength(1); - expect(ids.has("opencode-model")).toBe(true); - // Every one of these is in the live catalog and servable, and none was - // declared, so none may appear. - for (const undeclared of ["gpt-5.4", "claude-sonnet-4-5"]) { - expect(ids.has(undeclared), undeclared).toBe(false); - } - - stop?.(); - }); - - it("degrades to a no-op when the Host exposes no listing at all", () => { - const ctx = { llm: {} } as unknown as CordisContext; - expect(hideResponsesRoute(ctx, resolveConfig())).toBeUndefined(); - }); - - it("restores each listing method it replaced, by identity, on dispose", () => { - const { ctx } = hostWith(); - const llm = ctx.llm as Record; - const keys = [ - "listProviders", - "listConfigurableProviders", - "listModels", - ] as const; - const orig = Object.fromEntries(keys.map((key) => [key, llm[key]])); - const stop = hideResponsesRoute(ctx, resolveConfig()); - for (const key of keys) { - expect(llm[key], key).not.toBe(orig[key]); - } - - stop?.(); - for (const key of keys) { - expect(llm[key], key).toBe(orig[key]); - } - }); - - it("patches only the listing methods the host has", () => { - const listModels = async () => []; - const llm: Record = { listModels }; - const stop = hideResponsesRoute( - { llm } as unknown as CordisContext, - resolveConfig() - ); - expect(stop).toBeTypeOf("function"); - expect("listProviders" in llm).toBe(false); - expect("listConfigurableProviders" in llm).toBe(false); - expect(llm.listModels).not.toBe(listModels); - - stop?.(); - expect(llm.listModels).toBe(listModels); - }); - - it("the patched listing passes the caller's receiver to the host's method", async () => { - // Both methods read their state through `this`, so either would throw if - // the patch called the host's method detached. - const registry = { - routes: [ - { id: "opencode", name: "opencode" }, - { id: RESPONSES_ROUTE, name: RESPONSES_ROUTE }, - ], - listProviders(this: { routes: unknown[] }): unknown[] { - return [...this.routes]; - }, - async listModels( - this: { routes: unknown[] }, - provider: string - ): Promise { - return this.routes.length > 0 ? [{ id: `${provider}-model` }] : []; - }, - }; - const stop = hideResponsesRoute( - { llm: registry } as unknown as CordisContext, - resolveConfig() - ); - - expect(registry.listProviders()).toEqual([ - { id: "opencode", name: "opencode" }, - ]); - await expect(registry.listModels("opencode")).resolves.toEqual([ - { id: "opencode-model" }, - ]); - stop?.(); - }); - - it("keeps every listing when the host refuses one of them", () => { - // All or nothing, a deliberate change in P-H: the three listings must agree - // on what is hidden, so a host that will not take the `listModels` wrapper - // keeps all three as they were. Before, a THROWING refusal already rolled - // back like this, but a SILENT one (`Reflect.set` answering false on a - // non-writable method) left the other two filtered and returned a disposer. - const { ctx } = hostWith(); - const llm = ctx.llm as Record; - const { listConfigurableProviders, listModels, listProviders } = llm; - Object.defineProperty(llm, "listModels", { - configurable: false, - value: listModels, - writable: false, - }); - - expect(hideResponsesRoute(ctx, resolveConfig())).toBeUndefined(); - expect(llm.listProviders).toBe(listProviders); - expect(llm.listConfigurableProviders).toBe(listConfigurableProviders); - expect(llm.listModels).toBe(listModels); - expect(ctx.llm?.listProviders?.()).toHaveLength(2); - }); - - it("a reinstall still reads the host's own listing, so the redirect keeps firing", () => { - // A deliberate change in P-H. Before, the second install destructured the - // first install's filtered `listProviders` into `registeredRoutes`, so - // `isRouteRegistered` answered false. A reload's second install is handed - // the UNPATCHED listing, not the first install's filtered one, so - // `isRouteRegistered` keeps seeing the hidden route. A second install that stacked on the first would capture the - // filtered listing, and the redirect would never find its route. (The host - // here has no `adapters` map, so the listing is the only evidence.) - const { ctx } = hostWith(); - const a = hideResponsesRoute(ctx, resolveConfig()); - const b = hideResponsesRoute(ctx, resolveConfig()); - expect(ctx.llm?.listProviders?.()).toHaveLength(1); - expect(isRouteRegistered(RESPONSES_ROUTE)).toBe(true); - - a?.(); - expect(ctx.llm?.listProviders?.()).toHaveLength(1); - expect(isRouteRegistered(RESPONSES_ROUTE)).toBe(true); - b?.(); - expect(ctx.llm?.listProviders?.()).toHaveLength(2); - }); - - it("stands down without an llm service", () => { - expect( - hideResponsesRoute({} as CordisContext, resolveConfig()) - ).toBeUndefined(); - }); -}); - -describe("models-discovery: the unfiltered registry keeps its receiver", () => { - it("asks the Host's listing with the service as `this`", () => { - // The real `listProviders` reads its own state through `this`, so a method - // read off the service and called detached runs with `this === undefined` - // and surfaces as "Cannot read properties of undefined (reading 'adapters')" - // — naming the registry's internals rather than the call that broke the - // receiver. Every other test here uses an arrow, which cannot catch it. - const registry = { - adapters: new Map([["opencode", { id: "opencode" }]]), - listProviders(this: { adapters: Map }): unknown { - return [...this.adapters.values()]; - }, - }; - const ctx = { llm: registry } as unknown as CordisContext; - const stop = hideResponsesRoute(ctx, resolveConfig()); - expect(stop).toBeTypeOf("function"); - expect(isRouteRegistered("opencode")).toBe(true); - expect(isRouteRegistered("opencode-responses")).toBe(false); - stop?.(); - }); -}); - -describe("models-discovery: I1's second clause, for a model no catalog describes", () => { - it("offers a hand-listed model only where a route can serve it", () => { - // `undefined` is the completions default, which is right for a model that - // omits its SDK and wrong for one nobody has described. So the id is asked: - // a family whose protocol this plane has no route for is not offered. - // Google models now have a native bridge on both planes. - expect(isServableModel("gemini-3.8-flash", "opencode-go")).toBe(true); - // The same model on the plane that DOES carry the route stays offered. - expect(isServableModel("gemini-3.1-pro", "opencode")).toBe(true); - // And a family the rule does not name is left alone, because a missing - // catalog entry is not evidence of anything for it. - expect(isServableModel("deepseek-v4.1-flash", "opencode-go")).toBe(true); - expect(isServableModel("some-unknown-model", "opencode-go")).toBe(true); - }); - - it("keeps the catalog's own answer when there is one", () => { - // A model the catalog describes is answered by its SDK, and the id rule is - // never consulted - so this cannot change what already worked. - expect(isServableModel("gemini-3.1-pro", "opencode")).toBe(true); - }); - - it("leaves an undeclared plane alone, so a Go-only deployment is not touched", () => { - // The plane overrides are keyed on what the deployment DECLARES. Naming - // `opencode-go` and not `opencode` means the Zen route keeps exactly the - // behaviour it had, which is the difference between augmenting a route and - // replacing the user's choice. - expect(isServableModel("deepseek-v4.1-flash", "opencode")).toBe(true); - // The route is the only plane input there is; the same id on another plane - // is answered by that plane's own rules. - expect(isServableModel("deepseek-v4.1-flash", "opencode-go")).toBe(true); - }); - - it("offers Go Gemini through its native bridge", () => { - // The Go catalog names the Google protocol, served by the Go bridge. - expect(isServableModel("gemini-4-argon", "opencode-go")).toBe(true); - }); - - it("offers Zen's free tier models normally", () => { - expect(isServableModel("beam-free", "opencode")).toBe(true); - expect(isServableModel("beam-free", "opencode")).toBe(true); - expect(isServableModel("beam-free", "opencode")).toBe(true); - }); - - it("still offers the Go plane's free models, because Go has no free tier", () => { - expect(isServableModel("beam-free", "opencode-go")).toBe(true); - expect(isServableModel("beam-free", "opencode-go")).toBe(true); - // And a paid Zen model is untouched by the free-tier rule. - expect(isServableModel("deepseek-v4.1-flash", "opencode")).toBe(true); - }); -}); - -/** - * The picker's Effort row. `buildModelCatalog` in the session controller calls - * `ctx.llm.resolveModelInfo` per model and renders thinking-level choices only - * from what comes back, so a plane route whose adapter reports no `reasoning` - * shows the model with NO Effort row at all. These cases are that surface. - */ -describe("models-discovery: the adapter behind a plane route", () => { - /** - * A Host LLM service whose registry holds one adapter per route, as the real - * registry does - `adapters` is a public `Map` keyed by route id. - * - * The adapter carries the three methods the real one does. `modelAnswer` is - * what `modelOf` resolves, which is the object dispatch validates an effort - * against; it defaults to the same answer `resolveModel` gives. - */ - const llmWithAdapters = ( - answer: (provider: string, model: string) => unknown, - modelAnswer: (provider: string, model: string) => unknown = answer - ): { adapters: Map }> } => { - const adapters = new Map }>(); - for (const route of ["opencode", "opencode-go"]) { - const adapter: Record = { - modelOf: (_snapshot: unknown, provider: string, model: string) => - modelAnswer(provider, model), - resolveModel: async (provider: string, model: string) => - answer(provider, model), - }; - adapters.set(route, { adapter }); - } - return { adapters }; - }; - - /** The adapter one route holds, with `resolveModel` typed for the call. */ - const resolveOn = ( - llm: { adapters: Map }> }, - route: string - ): ((provider: string, model: string) => Promise) => { - const adapter = llm.adapters.get(route)?.adapter; - const fn = adapter?.resolveModel; - if (typeof fn !== "function") { - throw new TypeError(`no resolveModel on ${route}`); - } - return fn as (provider: string, model: string) => Promise; - }; - - /** The adapter one route holds, with `modelOf` typed for the call. */ - const modelOfOn = ( - llm: { adapters: Map }> }, - route: string - ): ((snapshot: unknown, provider: string, model: string) => unknown) => { - const adapter = llm.adapters.get(route)?.adapter; - const fn = adapter?.modelOf; - if (typeof fn !== "function") { - throw new TypeError(`no modelOf on ${route}`); - } - return fn as ( - snapshot: unknown, - provider: string, - model: string - ) => unknown; - }; - - /** The object dispatch reads, as far as the level map is concerned. */ - interface PiModel { - reasoning?: boolean; - thinkingLevelMap?: Record; - } - - /** - * The levels `decorateModelInfo` renders as this id's Effort row, from the same - * derivation it uses: our catalog's map for the model, minus the levels the map - * pins to `null`. This is the picker half of the promise dispatch has to keep. - */ - const pickerLevels = (plane: "go" | "zen", id: string): string[] => { - const spec = findModelSpecOn(plane, id); - const map = spec === undefined ? undefined : thinkingLevelMapFor(spec); - if (map === undefined) { - return []; - } - return PI_THINKING_LEVELS.filter((level) => map[level] !== null); - }; - - /** - * pi's own acceptance rule, which is what `resolveReasoningLevel` throws - * against - `getSupportedThinkingLevels` in pi-ai, copied verbatim into - * `dsh-llm-pi-ai`: no map or `reasoning: false` means `off` alone, a `null` - * entry refuses that level, and `xhigh`/`max` have to have an entry at all. - */ - const supportedLevels = (model: PiModel): string[] => { - if (model.reasoning !== true) { - return ["off"]; - } - return PI_THINKING_LEVELS.filter((level) => { - const mapped = model.thinkingLevelMap?.[level]; - if (mapped === null) { - return false; - } - if (level === "xhigh" || level === "max") { - return mapped !== undefined; - } - return true; - }); - }; - - it("fills what the adapter left blank, so dispatch sees the model's reasoning", async () => { - // The seam the modelOverrides attempt missed, and the half of it that reads - // this method (`modelOf` is the other - see the case below). The plane - // route's adapter resolves from pi's installed catalog, which carries no - // step-5 entry on either plane, so it answers nothing about the model and a - // saved effort then fails validation with UNSUPPORTED_REASONING_EFFORT. - const llm = llmWithAdapters(() => ({})); - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - - // The adapter answers an EMPTY record, exactly as pi's does for a model it - // does not carry. - const resolved = (await resolveOn(llm, "opencode-go")( - "opencode-go", - "beam-free" - )) as { - id?: string; - name?: string; - context?: { contextWindow?: number }; - defaultMaxTokens?: number; - reasoning?: { efforts: { id: string }[] }; - }; - - // Everything dispatch validates against, filled from our catalog. - expect(resolved.id).toBe("beam-free"); - expect(resolved.name).toBeTruthy(); - expect(resolved.context?.contextWindow).toBeGreaterThan(0); - expect(resolved.defaultMaxTokens).toBeGreaterThan(0); - // And the levels the composer offers - so a saved `high` resolves rather - // than being rejected. `beam-free` declares five efforts, and the point is - // that they come from OUR catalog rather than from the empty adapter answer. - expect(resolved.reasoning?.efforts.map((e) => e.id)).toEqual([ - "low", - "medium", - "high", - "xhigh", - "max", - ]); - - stop?.(); - }); - - it("keeps an adapter's own answer, because that is what dispatch honours", async () => { - // A COMPLETE answer, because the wrapper fills only what is absent and an - // identity field it adds is not it overriding anything. - const own = { - context: { contextWindow: 999 }, - defaultMaxTokens: 12345, - id: "beam-free", - inputModalities: ["text"], - name: "Its Own Name", - provider: "opencode", - reasoning: { efforts: [{ id: "high", name: "High" }] }, - }; - const llm = llmWithAdapters(() => own); - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - const resolved = (await resolveOn(llm, "opencode")( - "opencode", - "beam-free" - )) as Record; - // Every field the adapter answered is kept: it is what dispatch honours. - expect(resolved).toMatchObject(own); - // The ROUTING fields are the plugin's, and they are new here. The host - // dispatches on `model.api` (`dsh-llm-pi-ai`: - // `stream: (model, …) => input.api.stream(model, …)`), and it materializes a - // hand-declared model with the ROUTE's protocol — so a model whose catalog - // protocol differs is mis-dispatched unless the wrapper says otherwise. This - // is what lets one provider route serve every protocol its models name, - // instead of a provider per protocol. - expect(resolved.api).toBe("openai-completions"); - expect(resolved.baseUrl).toBe("https://opencode.ai/inference/openai/v1"); - stop?.(); - }); - - it("applies modelOverrides in decoratePlaneAdapters when declared", async () => { - const raw = { - context: { contextWindow: 1_000_000 }, - defaultMaxTokens: 32_000, - id: "gpt-5.4", - name: "GPT-5.4", - }; - const llm = llmWithAdapters(() => raw); - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ - enrichModels: true, - modelOverrides: { - "gpt-5.4": { - contextWindow: 65_536, - inputModalities: ["text"], - maxTokens: 8_192, - name: "GPT-5.4 Adapter Override", - reasoningEfforts: ["none", "medium"], - }, - }, - }) - ); - const resolved = (await resolveOn(llm, "opencode")( - "opencode", - "gpt-5.4" - )) as { - context?: { contextWindow?: number }; - defaultMaxTokens?: number; - inputModalities?: string[]; - name?: string; - reasoning?: { efforts: { id: string }[] }; - }; - expect(resolved.context?.contextWindow).toBe(65_536); - expect(resolved.defaultMaxTokens).toBe(8_192); - expect(resolved.name).toBe("GPT-5.4 Adapter Override"); - expect(resolved.inputModalities).toEqual(["text"]); - expect(resolved.reasoning?.efforts.map((e) => e.id)).toEqual([ - "none", - "medium", - ]); - stop?.(); - }); - - it("restores the adapter's own methods on dispose", () => { - const llm = llmWithAdapters(() => ({})); - const before = llm.adapters.get("opencode-go")?.adapter.resolveModel; - const beforeModelOf = llm.adapters.get("opencode-go")?.adapter.modelOf; - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - const after = llm.adapters.get("opencode-go")?.adapter.resolveModel; - expect(after).not.toBe(before); - expect(llm.adapters.get("opencode-go")?.adapter.modelOf).not.toBe( - beforeModelOf - ); - stop?.(); - expect(llm.adapters.get("opencode-go")?.adapter.resolveModel).toBe(before); - expect(llm.adapters.get("opencode-go")?.adapter.modelOf).toBe( - beforeModelOf - ); - }); - - it("stands down on a frozen adapter: nothing installed, nothing thrown", () => { - // A deliberate change in P-H. Before, `adapter[method] = wrapped` threw a - // TypeError out of the fetch-patch effect on a frozen adapter; now a refused - // method is skipped, and with every method refused there is nothing to - // dispose. - const adapter = Object.freeze({ - modelOf: () => ({}), - prepareCall: async () => ({}), - resolveModel: async () => ({}), - }); - const before = { ...adapter }; - const llm = { adapters: new Map([["opencode-go", { adapter }]]) }; - - expect( - decoratePlaneAdapters(llm as never, resolveConfig({ enrichModels: true })) - ).toBeUndefined(); - expect({ ...adapter }).toEqual(before); - }); - - it("skips one method the adapter refuses and patches the rest, per method", () => { - // A deliberate change in P-H. Before, `adapter[method] = wrapped` threw a - // TypeError on the non-writable `prepareCall`. Each adapter fill is - // independent, so a refused method costs only itself: unlike the listings, - // which must agree and are all or nothing. - const prepareCall = async () => ({}); - const adapter: Record = { - modelOf: () => ({}), - resolveModel: async () => ({}), - }; - Object.defineProperty(adapter, "prepareCall", { - configurable: false, - enumerable: true, - value: prepareCall, - writable: false, - }); - const { modelOf, resolveModel } = adapter; - const llm = { adapters: new Map([["opencode-go", { adapter }]]) }; - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - - expect(stop).toBeTypeOf("function"); - expect(adapter.prepareCall).toBe(prepareCall); - expect(adapter.resolveModel).not.toBe(resolveModel); - expect(adapter.modelOf).not.toBe(modelOf); - stop?.(); - expect(adapter.resolveModel).toBe(resolveModel); - expect(adapter.modelOf).toBe(modelOf); - expect(adapter.prepareCall).toBe(prepareCall); - }); - - it("calls the original on the adapter, because it is a class method that reads `this`", async () => { - // The registry holds CLASS instances, not bags of arrows: pi-ai's real - // `resolveModel` opens with `this.current()`. A wrapper that calls the - // extracted method detached fails the provider's entire catalog load with - // "Cannot read properties of undefined", which is the live symptom this - // case exists for. - class Adapter { - calls = 0; - async resolveModel(provider: string, model: string): Promise { - this.calls += 1; - return { id: model, provider }; - } - } - const adapter = new Adapter(); - const llm = { adapters: new Map([["opencode-go", { adapter }]]) }; - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - - const resolved = (await resolveOn(llm as never, "opencode-go")( - "opencode-go", - "beam-free" - )) as { context?: { contextWindow?: number } }; - - // It ran ON the instance - the counter lives on `this` - and the wrapper - // still filled the context pi does not carry. - expect(adapter.calls).toBe(1); - expect(resolved.context?.contextWindow).toBeGreaterThan(0); - - stop?.(); - }); - - it("leaves another provider's adapter alone", () => { - // Only the plane routes' adapters are this plugin's to replace; a foreign - // adapter in the same registry keeps its methods, and the stand-down says - // so by restoring nothing. - const foreign: Record = { - modelOf: () => ({}), - resolveModel: async () => ({}), - }; - const llm = { adapters: new Map([["commandcode", { adapter: foreign }]]) }; - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - - expect(llm.adapters.get("commandcode")?.adapter.resolveModel).toBe( - foreign.resolveModel - ); - expect(llm.adapters.get("commandcode")?.adapter.modelOf).toBe( - foreign.modelOf - ); - expect(stop).toBeUndefined(); - }); - - it("accepts at dispatch every level the picker offers, for a model pi does not carry", () => { - // The failure this exists for, in one case: `glm-5.3-flash-free` is on both - // planes and in neither of pi's installed catalogs, so the materialized model - // came back `reasoning: false` with no level map, the only level it took was - // `off`, and a saved `max` AND `high` were both refused with - // UNSUPPORTED_REASONING_EFFORT while the picker offered both. - const llm = llmWithAdapters( - () => ({}), - () => ({ id: "glm-5.3-flash-free", reasoning: false }) - ); - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - - const model = modelOfOn(llm, "opencode")( - undefined, - "opencode", - "glm-5.3-flash-free" - ) as PiModel; - - expect(model.reasoning).toBe(true); - // THE invariant, and the whole point of the wrap: every level the Effort row - // offers is a level this model answers. - const offered = pickerLevels("zen", "glm-5.3-flash-free"); - expect(offered.length).toBeGreaterThan(0); - expect(supportedLevels(model)).toEqual(expect.arrayContaining(offered)); - - stop?.(); - }); - - it("hands back the adapter's own object when it already takes every offered level", () => { - // The no-op branch: nothing is filled, so dispatch keeps the object the - // adapter made rather than an equal copy - `beam-free` ships a - // complete map, so there is nothing to add. - const own = { - id: "beam-free", - reasoning: true, - thinkingLevelMap: { - high: "high", - low: "low", - max: "max", - medium: "medium", - off: null, - xhigh: "xhigh", - }, - }; - const llm = llmWithAdapters( - () => ({}), - () => own - ); - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - - expect(modelOfOn(llm, "opencode")(undefined, "opencode", "beam-free")).toBe( - own - ); - - stop?.(); - }); - - it("fills only what the model refuses, and keeps the spelling the model declared", () => { - // A model can answer with a map of its own - pi ships one, and a deployment's - // `reasoningEfforts` row materializes as one - and that map is the adapter's - // answer about its own protocol, so it is not to be rewritten. Three things - // it does differently from ours, all of them kept: - // - // `off: "none"` a level we do not offer at all (never removed) - // `medium: "medium-plus"` a level it spells its own way (kept, not respelled) - // `low` absent a base level it takes implicitly, with `low` on the wire - // - // And one thing it refuses that the picker still offers - `xhigh: null` for - // this id - which is exactly what gets filled, because otherwise the Effort - // row promises a level `resolveReasoningLevel` throws on. - const declared = { - id: "qwen3.8-flash", - reasoning: true, - thinkingLevelMap: { medium: "medium-plus", off: "none", xhigh: null }, - }; - const llm = llmWithAdapters( - () => ({}), - () => ({ ...declared }) - ); - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - - const model = modelOfOn(llm, "opencode")( - undefined, - "opencode", - "qwen3.8-flash" - ) as PiModel; - - expect(model.thinkingLevelMap?.off).toBe("none"); - expect(model.thinkingLevelMap?.medium).toBe("medium-plus"); - expect(model.thinkingLevelMap?.low).toBeUndefined(); - expect(model.thinkingLevelMap?.xhigh).toBe("xhigh"); - const offered = pickerLevels("zen", "qwen3.8-flash"); - expect(offered).toEqual(expect.arrayContaining(["low", "medium", "xhigh"])); - expect(supportedLevels(model)).toEqual(expect.arrayContaining(offered)); - - stop?.(); - }); - - it("accepts the levels a deployment's own modelOverrides names", () => { - // The picker renders an override's ids verbatim, so dispatch has to take - // them: one promise, not two. - const llm = llmWithAdapters( - () => ({}), - () => ({ id: "glm-5.3-flash-free", reasoning: false }) - ); - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ - enrichModels: true, - modelOverrides: { - "glm-5.3-flash-free": { reasoningEfforts: ["off", "medium"] }, - }, - }) - ); - - const model = modelOfOn(llm, "opencode")( - undefined, - "opencode", - "glm-5.3-flash-free" - ) as PiModel; - - expect(supportedLevels(model)).toEqual( - expect.arrayContaining(["off", "medium"]) - ); - - stop?.(); - }); - - it("leaves alone an id the plane's own catalog does not carry", () => { - // "No opinion" is per PLANE, because the two are different catalogs: - // `step-5-preview` is Go's, so on Zen there is nothing to fill, and neither - // is anything invented for an id no catalog knows at all. - const goOnly = { id: "step-5-preview" }; - const foreign = { id: "not-a-catalogued-id" }; - const llm = llmWithAdapters( - () => ({}), - (_provider, model) => (model === goOnly.id ? goOnly : foreign) - ); - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - - // On its own plane it is catalogued and filled; on Zen it is left exactly as - // the adapter answered. - expect( - modelOfOn(llm, "opencode-go")(undefined, "opencode-go", goOnly.id) - ).not.toBe(goOnly); - expect(modelOfOn(llm, "opencode")(undefined, "opencode", goOnly.id)).toBe( - goOnly - ); - expect(modelOfOn(llm, "opencode")(undefined, "opencode", foreign.id)).toBe( - foreign - ); - - stop?.(); - }); - - it("one adapter serving both planes: the later plane's fill wraps the earlier one, in registry order", async () => { - // pi-ai serves both plane routes from ONE adapter instance, so the wrap is - // applied once per route and the later route's fill runs over the earlier - // one's answer. The base the earlier plane wrote is not a retired one, so - // the later fill keeps it: in registry order [opencode, opencode-go] both - // routes answer Zen's base. The host characterization's pi-ai-first - // `[INTENDED_CHANGE]` row pins the same nesting on the real host. - const specOn = (plane: "go" | "zen") => { - const spec = findModelSpecOn(plane, "deepseek-v4-pro"); - const protocol = spec === undefined ? undefined : protocolFor(spec); - if (protocol === undefined) { - throw new TypeError(`no protocol for deepseek-v4-pro on ${plane}`); - } - return protocol; - }; - const { endpoint } = resolveConfig(); - const zen = baseForProtocol("opencode", specOn("zen"), endpoint); - const go = baseForProtocol("opencode-go", specOn("go"), endpoint); - expect(zen).not.toBe(go); - - const baseFor = async ( - order: readonly string[] - ): Promise<{ calls: number; bases: unknown[] }> => { - const prepareCall = vi.fn(async () => ({})); - const adapter: Record = { prepareCall }; - const llm = { - adapters: new Map(order.map((route) => [route, { adapter }])), - }; - const stop = decoratePlaneAdapters(llm as never, resolveConfig()); - const call = adapter.prepareCall as ( - provider: string, - model: string - ) => Promise<{ baseUrl?: unknown }>; - const onGo = await call("opencode-go", "deepseek-v4-pro"); - const onZen = await call("opencode", "deepseek-v4-pro"); - const bases = [onGo.baseUrl, onZen.baseUrl]; - stop?.(); - return { bases, calls: prepareCall.mock.calls.length }; - }; - - await expect(baseFor(["opencode", "opencode-go"])).resolves.toEqual({ - bases: [zen, zen], - calls: 2, - }); - await expect(baseFor(["opencode-go", "opencode"])).resolves.toEqual({ - bases: [go, go], - calls: 2, - }); - }); - - it("a class adapter answers from its prototype again after dispose", async () => { - // A deliberate change in P-H. Before, the disposer assigned the prototype's - // method back as an OWN property, which answered the same but shadowed the - // prototype for good; now the own property is deleted. - class Adapter { - async resolveModel(_provider: string, model: string): Promise { - return { id: model, marker: "raw" }; - } - } - const adapter = new Adapter(); - const llm = { adapters: new Map([["opencode-go", { adapter }]]) }; - const stop = decoratePlaneAdapters(llm as never, resolveConfig()); - - const decorated = (await adapter.resolveModel( - "opencode-go", - "beam-free" - )) as { - context?: { contextWindow?: number }; - }; - expect(decorated.context?.contextWindow).toBeGreaterThan(0); - - stop?.(); - expect(Object.hasOwn(adapter, "resolveModel")).toBe(false); - await expect( - adapter.resolveModel("opencode-go", "beam-free") - ).resolves.toEqual({ id: "beam-free", marker: "raw" }); - }); - - it("stands down when the host exposes no adapter registry", () => { - expect(decoratePlaneAdapters({} as never, resolveConfig())).toBeUndefined(); - expect( - decoratePlaneAdapters(undefined as never, resolveConfig()) - ).toBeUndefined(); - }); -}); - -describe("models-discovery: the Effort row a model reports", () => { - /** A minimal Host `llm` service whose `resolveModelInfo` answers per route. */ - const llmWith = (answer: (provider: string, model: string) => unknown) => ({ - llm: { - resolveModelInfo: async (provider: string, model: string) => - answer(provider, model), - }, - }); - - const enrichConfig = resolveConfig({ enrichModels: true }); - const patched = ( - ctx: CordisContext - ): ((...a: unknown[]) => Promise) => - (ctx.llm as { resolveModelInfo: (...a: unknown[]) => Promise }) - .resolveModelInfo; - - it("survives a model the route's adapter refuses to resolve", async () => { - // The Host builds a provider's group with `Promise.all` over `listModels`, and - // a rejection there is caught per PROVIDER — so ONE unresolvable model removes - // EVERY model on that route from the picker. That became reachable when - // `listModels` started publishing the whole catalog on the parent route: the - // route's own adapter never carried the Responses / Messages / Google models, - // so it may refuse an id it does not know. - const ctx = { - llm: { - // Refuses every id, which is what an adapter does for a model its - // installed catalog does not carry — and is the worst case for the group. - resolveModelInfo: async () => { - throw new Error("unknown model"); - }, - }, - } as never as CordisContext; - const stop = decorateModelInfo(ctx, enrichConfig); - - // The unknown-to-the-adapter model answers from OUR catalog instead of - // throwing, so the group survives. - const resolved = (await patched(ctx)("opencode", "gpt-5.4")) as { - context?: { contextWindow?: number }; - reasoning?: { efforts: unknown[] }; - }; - expect(resolved.context?.contextWindow).toBeGreaterThan(0); - expect(resolved.reasoning?.efforts.length).toBeGreaterThan(0); - - // A model the catalog cannot describe either still throws: inventing an answer - // for an id nobody knows would hide a real problem. - await expect(patched(ctx)("opencode", "not-a-real-model")).rejects.toThrow( - "unknown model" - ); - - stop?.(); - }); - - it("leaves a provider it does not claim to its own adapter", async () => { - // The catalog is OpenCode's. A foreign route whose adapter refuses an id the - // catalog happens to share must keep its own failure, and one that answers - // must keep its own limits rather than OpenCode's. - const own = { - context: { contextWindow: 200_000 }, - id: "claude-sonnet-4-5", - }; - const ctx = { - llm: { - resolveModelInfo: async (provider: string) => { - if (provider === "anthropic-broken") { - throw new Error("adapter refused"); - } - return own; - }, - }, - } as never as CordisContext; - const stop = decorateModelInfo(ctx, enrichConfig); - await expect( - patched(ctx)("anthropic-broken", "claude-sonnet-4-5") - ).rejects.toThrow("adapter refused"); - expect(await patched(ctx)("anthropic", "claude-sonnet-4-5")).toBe(own); - // The claimed route still answers from the catalog, so the guard is not - // simply switching the decorator off. - const claimed = (await patched(ctx)("opencode", "claude-sonnet-4-5")) as { - context?: { contextWindow?: number }; - }; - expect(claimed.context?.contextWindow).not.toBe(200_000); - stop?.(); - }); - - it("turns a NON-RECORD answer into a catalog record, so the group survives", async () => { - // The quieter half of the same hazard. The Host reads `resolved.reasoning` - // immediately after `resolveModelInfo` returns, so an `undefined` answer is a - // TypeError inside the group's `Promise.all` — and the whole provider group - // goes with it, exactly as a throw would. - const ctx = { - llm: { - // Answers nothing at all for every id, which is what a permissive - // adapter does for a model its installed catalog does not carry. - resolveModelInfo: async () => { - /* answers nothing at all */ - }, - }, - } as never as CordisContext; - const stop = decorateModelInfo(ctx, enrichConfig); - - const resolved = (await patched(ctx)("opencode", "gpt-5.4")) as { - context?: { contextWindow?: number }; - defaultMaxTokens?: number; - reasoning?: { efforts: unknown[] }; - }; - expect(resolved).toBeTypeOf("object"); - expect(resolved.context?.contextWindow).toBeGreaterThan(0); - expect(resolved.defaultMaxTokens).toBeGreaterThan(0); - expect(resolved.reasoning?.efforts.length).toBeGreaterThan(0); - - // An id the catalog cannot describe still passes through unchanged: there is - // nothing honest to build, and the Host's own handling is the better error. - await expect( - patched(ctx)("opencode", "not-a-real-model") - ).resolves.toBeUndefined(); - - stop?.(); - }); - - it("fills the reasoning a plane route's adapter does not report", async () => { - const ctx = llmWith(() => ({ - id: "minimax-m3", - name: "MiniMax-M3", - provider: "opencode", - })) as never as CordisContext; - const stop = decorateModelInfo(ctx, enrichConfig); - - // The plane route is the user's own: it declares no models, so its adapter - // answers straight from the installed catalog and reports no reasoning. - const resolved = await patched(ctx)("opencode", "minimax-m3"); - expect(resolved).toMatchObject({ - context: { contextWindow: 512_000 }, - reasoning: { efforts: expect.any(Array) }, - }); - const { efforts } = ( - resolved as { reasoning: { efforts: { id: string; name: string }[] } } - ).reasoning; - expect(efforts.length).toBeGreaterThan(0); - for (const effort of efforts) { - expect(typeof effort.id).toBe("string"); - expect(typeof effort.name).toBe("string"); - // `off` is never offered: the map pins it to null, which is the - // adapter's way of saying the model has no off switch. - expect(effort.id).not.toBe("off"); - } - - stop?.(); - }); - - it("leaves the internal routes alone, because their adapter is already right", async () => { - const answer = (provider: string) => ({ - id: "minimax-m3", - name: "MiniMax-M3", - provider, - reasoning: { efforts: [{ id: "high", name: "High" }] }, - }); - const ctx = llmWith(answer) as never as CordisContext; - const stop = decorateModelInfo(ctx, enrichConfig); - - const resolved = await patched(ctx)("opencode-anthropic", "minimax-m3"); - // Unchanged: decorating our own route would risk disagreeing with dispatch. - expect(resolved).toEqual(answer("opencode-anthropic")); - - stop?.(); - }); - - it("overrides a STALE adapter answer with the catalog's fresher vintage", async () => { - // The reason this overrides rather than fills. pi ships its own installed - // `opencode` catalog the plane routes fall back to, and it is an older - // snapshot of models.dev: gpt-5.4 at 272k where the vendor and our shim both - // say 1.05M, claude-sonnet-4-5 at 200k with no thinking map where models.dev - // declares 1M and a token minimum. Filling only what was BLANK let those - // stale values survive, and the surfaces reported them. - const stale = { - context: { contextWindow: 272_000 }, - defaultMaxTokens: 64_000, - id: "gpt-5.4", - name: "GPT-5.4", - provider: "opencode", - // pi's stale map offers `medium`; ours says low/medium/high/xhigh. - reasoning: { - efforts: [ - { id: "off", name: "Off" }, - { id: "low", name: "Low" }, - { id: "medium", name: "Medium" }, - ], - }, - }; - const ctx = llmWith(() => stale) as never as CordisContext; - const stop = decorateModelInfo(ctx, enrichConfig); - - const resolved = (await patched(ctx)("opencode", "gpt-5.4")) as { - context?: { contextWindow?: number }; - defaultMaxTokens?: number; - reasoning?: { efforts: { id: string }[] }; - }; - // The window and the cap come from our catalog. - expect(resolved.context).toEqual({ contextWindow: 1_050_000 }); - expect(resolved.defaultMaxTokens).toBe(128_000); - // And so do the levels: `off` is not offered and `xhigh` now is. - expect(resolved.reasoning?.efforts.map((e) => e.id)).toEqual([ - "low", - "medium", - "high", - "xhigh", - ]); - // Everything the catalog does not decide is left as the adapter said it. - expect(resolved).toMatchObject({ id: "gpt-5.4", name: "GPT-5.4" }); - - stop?.(); - }); - - it("leaves a model the catalog does not describe to its adapter", async () => { - // The other half of the authority argument: our catalog decides the fields - // IT carries, and a model it has never heard of is not ours to correct. A - // hand-declared model in a deployment's profile keeps whatever its adapter - // says, including a reasoning block this plugin knows nothing about. - const own = { - context: { contextWindow: 4096 }, - id: "hand-declared-model", - name: "Hand Declared", - provider: "opencode", - reasoning: { efforts: [{ id: "high", name: "High" }] }, - }; - const ctx = llmWith(() => own) as never as CordisContext; - const stop = decorateModelInfo(ctx, enrichConfig); - - const resolved = await patched(ctx)("opencode", "hand-declared-model"); - expect(resolved).toEqual(own); - - stop?.(); - }); - - it("restores the original method on dispose, and only if it is still ours", () => { - const before = async () => ({ id: "x" }); - const ctx = { llm: { resolveModelInfo: before } } as never as CordisContext; - const stop = decorateModelInfo(ctx, enrichConfig); - - expect(patched(ctx)).not.toBe(before); - stop?.(); - expect(patched(ctx)).toBe(before); - }); - - it("calls the host's method with the service as its receiver", async () => { - class InfoHost { - answers = new Map([ - ["hand-declared-model", { id: "hand-declared-model", name: "Mine" }], - ]); - async resolveModelInfo( - _provider: string, - model: string - ): Promise { - return this.answers.get(model); - } - } - const llm = new InfoHost(); - const stop = decorateModelInfo( - { llm } as unknown as CordisContext, - enrichConfig - ); - - // Uncatalogued, so the answer passes through - read off `this`. - await expect( - llm.resolveModelInfo("opencode", "hand-declared-model") - ).resolves.toEqual({ id: "hand-declared-model", name: "Mine" }); - stop?.(); - }); - - it("leaves a wrapper installed on top of it in place on dispose", () => { - const before = async () => ({ id: "x" }); - const ctx = { llm: { resolveModelInfo: before } } as never as CordisContext; - const stop = decorateModelInfo(ctx, enrichConfig); - const foreign = async () => ({ id: "foreign" }); - (ctx.llm as Record).resolveModelInfo = foreign; - stop?.(); - - expect(patched(ctx)).toBe(foreign); - }); - - it("leaves a non-extensible service installed", () => { - const before = async () => ({ id: "x" }); - const ctx = { - llm: Object.freeze({ resolveModelInfo: before }), - } as never as CordisContext; - - expect(decorateModelInfo(ctx, enrichConfig)).toBeUndefined(); - expect(patched(ctx)).toBe(before); - }); - - it("installs nothing when the plugin is not enriching models", () => { - const before = async () => ({ id: "x" }); - const ctx = { llm: { resolveModelInfo: before } } as never as CordisContext; - expect( - decorateModelInfo(ctx, resolveConfig({ enrichModels: false })) - ).toBeUndefined(); - expect(patched(ctx)).toBe(before); - }); - - it("clamps context window when a deployment maxContextWindow ceiling is configured", async () => { - const original = { id: "gpt-5.4", name: "GPT-5.4" }; - const ctx = llmWith(() => original) as never as CordisContext; - // Set deployment context ceiling to 131,072 tokens (clamping gpt-5.4 from 1,050,000) - const customConfig = resolveConfig({ - enrichModels: true, - maxContextWindow: 131_072, - }); - const stop = decorateModelInfo(ctx, customConfig); - - const resolved = (await patched(ctx)("opencode", "gpt-5.4")) as { - context?: { contextWindow?: number }; - }; - expect(resolved.context?.contextWindow).toBe(131_072); - stop?.(); - }); - - it("preserves custom properties on existing context objects", async () => { - const withCustomContext = { - context: { contextWindow: 65_536, reservedTokens: 4096 }, - id: "gpt-5.4", - name: "GPT-5.4", - }; - const ctx = llmWith(() => withCustomContext) as never as CordisContext; - const stop = decorateModelInfo(ctx, enrichConfig); - - const resolved = (await patched(ctx)("opencode", "gpt-5.4")) as { - context?: { contextWindow?: number; reservedTokens?: number }; - }; - // Canonical catalog spec for gpt-5.4 (1,050,000) overrides adapter context window, - // while preserving custom fields on context (like reservedTokens). - expect(resolved.context?.contextWindow).toBe(1_050_000); - // Custom context property is preserved rather than wiped - expect(resolved.context?.reservedTokens).toBe(4096); - stop?.(); - }); - - it("applies per-model configuration overrides for context window, caps, and metadata", async () => { - const original = (_provider: string, model: string) => ({ - id: model, - name: model === "gpt-5.4" ? "GPT-5.4" : "Claude Sonnet 4.5", - }); - const ctx = llmWith(original) as never as CordisContext; - const configWithOverrides = resolveConfig({ - enrichModels: true, - modelOverrides: { - "gpt-5.4": { - contextWindow: 65_536, - inputModalities: ["text"], - maxTokens: 16_384, - name: "GPT-5.4 Custom Name", - reasoningEfforts: ["none", "high"], - }, - }, - }); - const stop = decorateModelInfo(ctx, configWithOverrides); - - const resolved = (await patched(ctx)("opencode", "gpt-5.4")) as { - context?: { contextWindow?: number }; - defaultMaxTokens?: number; - inputModalities?: string[]; - name?: string; - reasoning?: { efforts: { id: string; name: string }[] }; - }; - expect(resolved.context?.contextWindow).toBe(65_536); - expect(resolved.defaultMaxTokens).toBe(16_384); - expect(resolved.name).toBe("GPT-5.4 Custom Name"); - expect(resolved.inputModalities).toEqual(["text"]); - expect(resolved.reasoning?.efforts).toEqual([ - { id: "none", name: "None" }, - { id: "high", name: "High" }, - ]); - - // Unrelated models keep their canonical specs - const sonnet = (await patched(ctx)("opencode", "claude-sonnet-4-5")) as { - context?: { contextWindow?: number }; - name?: string; - }; - expect(sonnet.context?.contextWindow).toBe(1_000_000); - expect(sonnet.name).toBe("Claude Sonnet 4.5"); - - stop?.(); - }); -}); - -describe("models-discovery: resolveEffectiveContextWindow", () => { - it("returns canonical window when neither requested window nor ceiling is set", () => { - expect(resolveEffectiveContextWindow(1_050_000)).toBe(1_050_000); - expect(resolveEffectiveContextWindow(200_000)).toBe(200_000); - }); - - it("clamps requested window to canonical window if requested exceeds physical capability", () => { - // If user/caller requests 2,000,000 tokens on a 1,050,000 model, it clamps to physical limit - expect(resolveEffectiveContextWindow(1_050_000, 2_000_000)).toBe(1_050_000); - }); - - it("takes the window the deployment declared, even above the catalog's", () => { - // The catalog is a bundled snapshot of the vendor's published limits and it is - // conservative where the vendor is: the free DeepSeek id is listed at 200k - // while the model serves 1M. A declared window therefore wins outright — - // `Math.min(requested, canonical)` used to clamp it back down, which made a - // stale catalog entry unoverridable. - expect(resolveEffectiveContextWindow(1_000_000)).toBe(1_000_000); - expect(resolveEffectiveContextWindow(2_000_000)).toBe(2_000_000); - }); - - it("still clamps to an explicitly configured ceiling", () => { - // `maxContextWindow` is the deployment's own bound, which is a different thing - // from our estimate of the model's. - expect(resolveEffectiveContextWindow(2_000_000, 262_144)).toBe(262_144); - expect(resolveEffectiveContextWindow(100_000, 262_144)).toBe(100_000); - }); - - it("ignores invalid, non-positive or non-finite values", () => { - expect(resolveEffectiveContextWindow(1_050_000, "invalid")).toBe(1_050_000); - expect(resolveEffectiveContextWindow(1_050_000, null)).toBe(1_050_000); - expect( - resolveEffectiveContextWindow(1_050_000, Number.POSITIVE_INFINITY) - ).toBe(1_050_000); - expect(resolveEffectiveContextWindow(0)).toBe(0); - expect(resolveEffectiveContextWindow(Number.NaN)).toBe(0); - }); -}); - -describe("models-discovery: a keyless deployment is offered only what it can call", () => { - /** - * A host whose parent route lists one priced model and one free one. - * - * The official client's rule (`provider/provider.ts:231-237`) is to delete every - * model whose `cost.input` is not 0 when no credential is present; without it the - * picker offers rows that answer 401 and says nothing about why. - */ - const hostListing = (ids: readonly string[]) => - ({ - llm: { - listConfigurableProviders: () => [{ provider: "opencode" }], - listModels: async (provider: string) => - provider === "opencode" ? ids.map((id) => ({ id })) : [], - listProviders: () => [{ id: "opencode", name: "opencode" }], - }, - }) as unknown as CordisContext; - - const listedFor = async ( - ctx: CordisContext, - verify?: (plane: string) => boolean - ): Promise => { - const stop = hideResponsesRoute( - ctx, - resolveConfig(), - verify === undefined ? {} : { hasCredential: verify } - ); - const listed = (await ctx.llm?.listModels?.("opencode")) as Array<{ - id: string; - }>; - stop?.(); - return listed.map((model) => model.id); - }; - - it("drops the priced model when no credential resolves", async () => { - const ids = await listedFor( - hostListing(["gpt-5.4", "beam-free"]), - () => false - ); - expect(ids).toEqual(["beam-free"]); - }); - - it("keeps every model when the plane does hold a credential", async () => { - const ids = await listedFor( - hostListing(["gpt-5.4", "beam-free"]), - () => true - ); - expect(ids).toEqual(["gpt-5.4", "beam-free"]); - }); - - it("leaves the list untouched when the caller does not ask", async () => { - // The default is "do not narrow": a caller that cannot tell must not hide - // models a deployment can actually use. - const ids = await listedFor(hostListing(["gpt-5.4", "beam-free"])); - expect(ids).toEqual(["gpt-5.4", "beam-free"]); - }); -}); - -describe("models-discovery · the endpoint a model is dispatched to", () => { - /** One plane-route adapter answering with a fixed model, then resolve it. */ - const resolveBase = async ( - own: Record, - plan: { model?: string; route?: string } = {} - ): Promise => { - const route = plan.route ?? "opencode"; - const model = plan.model ?? "beam-free"; - const answer = () => ({ - context: { contextWindow: 999 }, - id: model, - inputModalities: ["text"], - name: "Space Bunny", - provider: route, - ...own, - }); - const adapter: Record = { - modelOf: (_snapshot: unknown) => answer(), - resolveModel: async () => answer(), - }; - const llm = { adapters: new Map([[route, { adapter }]]) }; - const stop = decoratePlaneAdapters( - llm as never, - resolveConfig({ enrichModels: true }) - ); - const resolveModel = adapter.resolveModel as ( - provider: string, - model: string - ) => Promise; - // The MODEL id matters: without one the wrapper has no catalog entry to work - // from and passes the adapter's answer through untouched. - const resolved = (await resolveModel(route, model)) as Record< - string, - unknown - >; - stop?.(); - return resolved.baseUrl; - }; - - it("replaces the vendor's retired base with the current family endpoint", async () => { - // pi's installed catalog still says `https://opencode.ai/zen`, and a profile - // written for the old gateway says `…/zen/v1`. Both are aliases today, but the - // per-family address is what the gateway serves, and an anthropic model on a - // `/zen` base is simply wrong. - expect(await resolveBase({ baseUrl: "https://opencode.ai/zen" })).toBe( - "https://opencode.ai/inference/openai/v1" - ); - expect(await resolveBase({ baseUrl: "https://opencode.ai/zen/v1" })).toBe( - "https://opencode.ai/inference/openai/v1" - ); - }); - - it("repairs the OTHER plane's retired base too", async () => { - // The Go plane's old address used to be exempt, because the set was written - // out by hand and carried only Zen. A profile declaring `…/zen/go/v1` was - // therefore never repaired, so `endpoint: auto` followed the catalog on one - // plane and a pre-move address on the other. Measured against the gateway: - // both families answer a real completion on Go (200 with a session header), - // so the exemption bought nothing but the drift. - expect( - await resolveBase( - { baseUrl: "https://opencode.ai/zen/go/v1" }, - { model: "deepseek-v4-pro", route: "opencode-go" } - ) - ).toBe("https://opencode.ai/inference/go/openai/v1"); - // And the repair is still narrow: a proxy is still nobody's business. - expect( - await resolveBase( - { baseUrl: "https://gateway.internal/zen/go/v1" }, - { model: "deepseek-v4-pro", route: "opencode-go" } - ) - ).toBe("https://gateway.internal/zen/go/v1"); - }); - - it("leaves any other endpoint alone", async () => { - // A deployment pointing this route at its own proxy must not have the - // endpoint rewritten by a bundled snapshot. - expect( - await resolveBase({ baseUrl: "https://gateway.internal/zen/v1" }) - ).toBe("https://gateway.internal/zen/v1"); - }); -}); diff --git a/test/mount-protocols.test.ts b/test/mount-protocols.test.ts index 5b071dfa..bc084f2b 100644 --- a/test/mount-protocols.test.ts +++ b/test/mount-protocols.test.ts @@ -1,5 +1,3 @@ -import { supportedProtocols } from "@deepseek-ai/dsh-llm-pi-ai"; -import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; import { describe, expect, it } from "vitest"; @@ -9,73 +7,19 @@ import { OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, PROTOCOL_FOR_SDK, - protocolFor, - ROUTE_FOR_PLANE_PROTOCOL, - SELF_MOUNTED_ROUTES, - UNSERVED_PLANE_SDKS, - UNSERVED_SDKS, + protocolForSdk, + PROTOCOLS, } from "./support/src.ts"; -/** - * The protocols this plugin mounts must be ones the harness's own pi-ai layer - * can build, and every SDK the catalog names must map to one of them. - * - * Two claims, each against the real package rather than a stand-in: - * - * - The routes the WRAPPER mounts may only name a protocol in - * `dsh-llm-pi-ai`'s own `PROTOCOLS` table, which refuses anything else - * outright. That is how `mistral-conversations` once shipped: it is in - * `COMPAT_GATES`, which looks like a protocol registry and is not one. - * - Every catalog row resolves, through `protocolFor` (which reads - * `GATEWAY_PROTOCOL_FOR_SDK`), to a protocol pi's exported `api/*` modules - * implement — independent of which models any of pi's own gateway providers - * happen to list. The plugin's own SDK table, `PROTOCOL_FOR_SDK` - * (`src/responses-routes.ts`), is checked against the same modules as a - * second step. In this file "the wrapper's table" means only - * `dsh-llm-pi-ai`'s `PROTOCOLS`. +/* + * The protocols the plane adapter serves are the four `PROTOCOLS` names, each + * built from pi's own `api/.lazy` module (`buildPlaneProvider`), and + * every SDK the catalog names maps to one of them. Both claims are made + * against pi's exported implementations below, not a table kept here. * * The pi and harness versions these run against are named once, in * `test/support/host-versions.ts`. */ -describe("mount · the protocols the harness can build", () => { - it("names only protocols dsh-llm-pi-ai supports, for the routes it mounts", () => { - // The wrapper's mount may only name a protocol in its own table, so the - // claim splits by mechanism: routes the wrapper mounts are claimed - // against its table, and routes the plugin mounts itself are claimed - // against pi directly, in the companion below. - const supported = new Set(supportedProtocols()); - const named = [ - ...new Set( - Object.values(ROUTE_FOR_PLANE_PROTOCOL).flatMap((plane) => - Object.entries(plane.routes) - .filter(([, route]) => !SELF_MOUNTED_ROUTES.has(route)) - .map(([protocol]) => protocol) - ) - ), - ]; - expect(named.filter((protocol) => !supported.has(protocol))).toEqual([]); - }); - - it("serves every self-mounted route a protocol pi itself implements", () => { - // The wrapper's table refuses to name these — that refusal is why the - // bridge exists — so the claim to verify is the different one: pi ships - // the implementation the bridge hands its adapter, with the two stream - // entry points the provider delegates to. - const api = googleGenerativeAIApi(); - expect(typeof api.stream).toBe("function"); - expect(typeof api.streamSimple).toBe("function"); - const selfMounted = Object.values(ROUTE_FOR_PLANE_PROTOCOL).flatMap( - (plane) => - Object.entries(plane.routes) - .filter(([, route]) => SELF_MOUNTED_ROUTES.has(route)) - .map(([protocol]) => protocol) - ); - expect(selfMounted).toEqual([ - "google-generative-ai", - "google-generative-ai", - ]); - }); -}); /** * What the harness's pi-ai layer can do for the gateway, measured against the @@ -125,8 +69,9 @@ describe("mount · what pi-ai's own gateway providers cover", () => { it(`characterization (F10): pi ${HOST_VERSIONS.pi}'s opencode-go provider has no google-generative-ai`, () => { // A characterization of the pinned pi (`HOST_VERSIONS.pi`), not a // requirement: its Go provider lists no model on the Google protocol, - // while the gateway's Go catalog does carry one. That gap is what the - // bridge serves today. pi 1.3.0 added a Go Google row + // while the gateway's Go catalog does carry one. The plane adapter serves + // that gap from the catalog (D16) with pi's own Google module, on Go's + // one Google base. pi 1.3.0 added a Go Google row // (`gemini-4-argon-contributor`), which is why the provider-based routing // check that used to stand below passed on the newer pi and not this one. const go = providers.find((p) => p.id === "opencode-go"); @@ -149,7 +94,7 @@ describe("mount · what pi-ai's own gateway providers cover", () => { * implement. * * The per-model `api` in our catalog is the vendor's own SDK (`@ai-sdk/…`), and - * `protocolFor` turns it into a wire protocol — `openai-completions` for a row + * `protocolForSdk` turns it into a wire protocol — `openai-completions` for a row * that names no SDK, the provider's own default. This walks the whole catalog * rather than a sample, because the failure this catches is one model in a * refreshed catalog naming an SDK nobody has a protocol for - and a picker that @@ -161,12 +106,8 @@ describe("mount · what pi-ai's own gateway providers cover", () => { * whether it carried a model on the same protocol, which made the answer a * fact about pi's catalog: pi 1.3.0 listed a Go Gemini model and so passed, * while the pinned pi 1.1.0 lists none (characterized above, F10) although the - * bridge serves those models through the same `google-generative-ai` module. - * Models whose SDK is a recorded decision (`UNSERVED_SDKS`) are skipped: that - * is the plugin saying so, not a gap. Plane-level decisions in - * `UNSERVED_PLANE_SDKS` likewise, so the rows here are the rows the picker - * offers (`isServableModel`, src/models-discovery.ts) and `test/catalog.test.ts` - * walks. + * plane adapter serves those models through the same `google-generative-ai` + * module. No SDK is withheld any more (D16), so every catalog row is walked. */ describe("routing · every catalog SDK maps to a protocol pi's api/* modules implement", () => { /** @@ -206,20 +147,11 @@ describe("routing · every catalog SDK maps to a protocol pi's api/* modules imp ["opencode-go", OPENCODE_GO_CATALOG], ] as const ).flatMap(([plane, catalog]) => - catalog - .filter( - (model) => - model.provider_npm === undefined || - // The `${plane} + ${sdk}` key is the one `isServableModel` reads - // (the route ids `opencode` / `opencode-go`). The set is empty - // today; an entry that withholds a protocol on every plane then - // turns the "four protocols" constant below red, for review. - !( - UNSERVED_SDKS.has(model.provider_npm) || - UNSERVED_PLANE_SDKS.has(`${plane} + ${model.provider_npm}`) - ) - ) - .map((model) => ({ model, plane, protocol: protocolFor(model) })) + catalog.map((model) => ({ + model, + plane, + protocol: protocolForSdk(model.provider_npm), + })) ); it("names no SDK whose protocol pi does not implement", async () => { @@ -231,6 +163,7 @@ describe("routing · every catalog SDK maps to a protocol pi's api/* modules imp [ ...rows.map((row) => row.protocol), ...Object.values(PROTOCOL_FOR_SDK), + ...PROTOCOLS, ].filter((name) => name !== undefined) ), ]; @@ -249,10 +182,11 @@ describe("routing · every catalog SDK maps to a protocol pi's api/* modules imp `${row.plane} ${row.model.id}: ${row.model.provider_npm ?? "(default)"} -> ${row.protocol ?? "no protocol"}` ); expect(unsupported).toEqual([]); - // The plugin's own SDK table (`PROTOCOL_FOR_SDK`, src/responses-routes.ts) - // names nothing pi lacks either. + // The plugin's own SDK table (`PROTOCOL_FOR_SDK`, src/utils/protocols.ts) + // and the protocols the plane provider is built from name nothing pi + // lacks either. expect( - Object.values(PROTOCOL_FOR_SDK).filter( + [...Object.values(PROTOCOL_FOR_SDK), ...PROTOCOLS].filter( (protocol) => verdicts.get(protocol) !== true ) ).toEqual([]); @@ -272,6 +206,13 @@ describe("routing · every catalog SDK maps to a protocol pi's api/* modules imp "openai-completions", "openai-responses", ]); + // And it is the set the plane provider is built from, no more. + expect([...PROTOCOLS].toSorted()).toEqual([ + "anthropic-messages", + "google-generative-ai", + "openai-completions", + "openai-responses", + ]); }); it("answers false for a protocol pi does not ship", async () => { diff --git a/test/pi-provider.test.ts b/test/pi-provider.test.ts deleted file mode 100644 index 791d1602..00000000 --- a/test/pi-provider.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { createProvider } from "@earendil-works/pi-ai"; -import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy"; -import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; -import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; -import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; -import { describe, expect, it } from "vitest"; - -import { - BASE_FOR_PROTOCOL, - type CatalogModelSpec, - ENDPOINT_FAMILIES, - OPENCODE_PLANE_API, - OPENCODE_USER_AGENT, - baseForProtocol, - piModelFor, - planeBaseFor, - protocolFor, - thinkingLevelMapFor, -} from "./support/src.ts"; - -/** - * The headers this test feeds the model. - * - * A fixture, and it lives here because that is what it is. It was exported from - * `pi-provider.ts` and called only from this file, and it had drifted: it hardcodes - * `x-opencode-client` and `x-opencode-project` where `fetch-patch.ts`, which - * actually sends those headers, reads both from config. A fixture that disagrees - * with the thing it stands in for is worse than no fixture, and production should - * not carry one. - */ -const gatewayHeaders = ( - sessionId: string | undefined, - userAgent: string -): Record => ({ - "user-agent": userAgent, - "x-opencode-client": "cli", - "x-opencode-project": "global", - ...(sessionId === undefined ? {} : { "x-opencode-session": sessionId }), -}); - -/** A catalogued model, shaped exactly as `catalog-data.ts` ships them. */ -const spec = (id: string, providerNpm?: string): CatalogModelSpec => ({ - context_window: 200_000, - cost: { cache_read: 0.01, input: 0.1, output: 0.5 }, - id, - input_modalities: ["text"], - max_output_tokens: 64_000, - name: id, - ...(providerNpm === undefined ? {} : { provider_npm: providerNpm }), -}); - -/** Wrap one api so every request carries the plugin's gateway headers. */ -const withHeaders = ( - streams: Record unknown>, - sessionId: string -) => { - const wrap = - (fn: (...args: never[]) => unknown) => - (model: never, context: never, options: Record) => - fn(model, context, { - ...options, - headers: { - ...gatewayHeaders(sessionId, "opencode/1.19.4 dsh-opencode-patch"), - ...(options?.headers as Record | undefined), - }, - } as never); - return { - ...streams, - stream: wrap(streams.stream as (...args: never[]) => unknown), - streamSimple: wrap(streams.streamSimple as (...args: never[]) => unknown), - }; -}; - -/** - * The request a provider would send for one model, captured before it leaves. - * - * The spike that designed this module did the same thing by hand; this is that - * spike, kept. - */ -const requestFor = async ( - plane: "opencode" | "opencode-go", - model: CatalogModelSpec -): Promise<{ headers: Record; url: string }> => { - const protocol = protocolFor(model); - expect(protocol).toBeDefined(); - const provider = createProvider({ - api: { - "anthropic-messages": withHeaders( - anthropicMessagesApi() as never, - "ses_test" - ), - "google-generative-ai": withHeaders( - googleGenerativeAIApi() as never, - "ses_test" - ), - "openai-completions": withHeaders( - openAICompletionsApi() as never, - "ses_test" - ), - "openai-responses": withHeaders( - openAIResponsesApi() as never, - "ses_test" - ), - }, - auth: { apiKey: { name: "OpenCode API key" } }, - id: plane, - models: [piModelFor(plane, model, protocol ?? "")], - name: plane, - } as never); - - const seen: { headers: Record; url: string }[] = []; - const real = globalThis.fetch; - // Some protocols call fetch with a Request rather than a URL plus init, so - // the headers have to be read off whichever shape arrived. - globalThis.fetch = (async (input: unknown, init?: { headers?: unknown }) => { - const url = typeof input === "string" ? input : String(input); - const request = typeof input === "object" && input !== null ? input : {}; - const raw = - init?.headers ?? (request as { headers?: unknown }).headers ?? {}; - // A `Headers` instance carries its entries behind a method, not as own - // properties, so reading it with Object.entries yields nothing at all. - const headers: Record = {}; - if (raw instanceof Headers) { - for (const [key, value] of raw.entries()) { - headers[key.toLowerCase()] = value; - } - } else { - for (const [key, value] of Object.entries( - raw as Record - )) { - headers[key.toLowerCase()] = value; - } - } - seen.push({ headers, url }); - throw new Error("captured"); - }) as typeof globalThis.fetch; - try { - const [only] = provider.getModels(); - expect(only).toBeDefined(); - const stream = provider.stream( - only as never, - { messages: [{ role: "user", content: "hi" }] } as never, - { apiKey: "sk-test", sessionId: "ses_test" } as never - ); - const iterable = stream as unknown as AsyncIterable; - for await (const _ of iterable) { - // The capture throws on the first request; nothing arrives here. - } - } catch { - // Expected: the capture refuses the call. - } finally { - globalThis.fetch = real; - } - expect(seen.length).toBe(1); - return seen[0] ?? { headers: {}, url: "" }; -}; - -describe("pi-provider · the URL each protocol reaches", () => { - it("sends a Responses model to the responses endpoint", async () => { - const { url } = await requestFor( - "opencode", - spec("gpt-5.4", "@ai-sdk/openai") - ); - expect(url).toBe(`${OPENCODE_PLANE_API.zen}/responses`); - }); - - it("sends a Messages model to the messages endpoint", async () => { - const { url } = await requestFor( - "opencode", - spec("claude-opus-5", "@ai-sdk/anthropic") - ); - // The Anthropic family has its own base, one level shorter than its - // declared `…/anthropic/v1` because this SDK appends /v1/messages. - expect(url).toBe( - "https://opencode.ai/inference/anthropic/v1/messages?beta=true" - ); - }); - - it("sends a model with no SDK to the completions endpoint", async () => { - const { url } = await requestFor("opencode", spec("big-pickle")); - expect(url).toBe(`${OPENCODE_PLANE_API.zen}/chat/completions`); - }); - - it("sends a Google model to the endpoint a hand-declared route cannot name", async () => { - const { url } = await requestFor( - "opencode", - spec("gemini-3.8-flash", "@ai-sdk/google") - ); - expect(url).toBe( - "https://opencode.ai/inference/google/v1beta/models/gemini-3.8-flash:streamGenerateContent?alt=sse" - ); - }); - - it("sends a Go-plane model to the Go base", async () => { - const { url } = await requestFor( - "opencode-go", - spec("deepseek-v4-flash", "@ai-sdk/openai") - ); - expect(url).toBe(`${OPENCODE_PLANE_API.go}/responses`); - }); -}); - -describe("pi-provider · the headers every protocol carries", () => { - it("puts the session id on all four protocols", async () => { - const cases: [string, string | undefined][] = [ - ["gpt-5.4", "@ai-sdk/openai"], - ["claude-opus-5", "@ai-sdk/anthropic"], - ["big-pickle", undefined], - ["gemini-3.8-flash", "@ai-sdk/google"], - ]; - for (const [id, sdk] of cases) { - const { headers } = await requestFor("opencode", spec(id, sdk)); - expect(headers["x-opencode-session"]).toBe("ses_test"); - expect(headers["x-opencode-client"]).toBe("cli"); - expect(headers["x-opencode-project"]).toBe("global"); - } - }); -}); - -describe("pi-provider · the tables themselves", () => { - it("keeps the Anthropic base one level shorter on both planes", () => { - for (const plane of ["opencode", "opencode-go"] as const) { - const base = BASE_FOR_PROTOCOL[plane]; - expect(base["anthropic-messages"]?.endsWith("/v1")).toBe(false); - expect(base["openai-responses"]?.endsWith("/v1")).toBe(true); - } - }); - - it("gives each wire family its own base on the inference family", () => { - // **Measured from the vendor's `GET /console/api/config`**, not derived. The - // inference family is NOT one base per plane: anthropic and google each live - // at their own path, and 26 of the zen catalog's models (18 anthropic, 8 - // google) are reachable only through theirs. Deriving them by stripping `/v1` - // off one plane URL sent every one of them to `…/inference/openai`. - expect(BASE_FOR_PROTOCOL).toEqual({ - opencode: { - "anthropic-messages": "https://opencode.ai/inference/anthropic", - "google-generative-ai": "https://opencode.ai/inference/google/v1beta", - "openai-completions": "https://opencode.ai/inference/openai/v1", - "openai-responses": "https://opencode.ai/inference/openai/v1", - }, - "opencode-go": { - "anthropic-messages": "https://opencode.ai/inference/go/anthropic", - "google-generative-ai": - "https://opencode.ai/inference/go/google/v1beta", - "openai-completions": "https://opencode.ai/inference/go/openai/v1", - "openai-responses": "https://opencode.ai/inference/go/openai/v1", - }, - }); - // The Anthropic entry is the family's declared base minus its version - // segment, because pi-ai's SDK appends `/v1/messages` itself. - }); - - it("keeps the legacy family on one base for every protocol", () => { - // The older family genuinely is one address, which is why it keeps the old - // derivation while the inference family no longer does. - for (const plane of ["opencode", "opencode-go"] as const) { - const zen = - plane === "opencode" - ? "https://opencode.ai/zen/v1" - : "https://opencode.ai/zen/go/v1"; - for (const protocol of [ - "openai-completions", - "openai-responses", - "google-generative-ai", - ]) { - expect( - baseForProtocol(plane, protocol, "legacy"), - `${plane}/${protocol}` - ).toBe(zen); - } - // Anthropic is the one protocol whose base is the family's minus `/v1`. - expect(baseForProtocol(plane, "anthropic-messages", "legacy")).toBe( - zen.replace(/\/v1$/, "") - ); - } - }); - - it("resolves each endpoint family to its own base, and auto as modern", () => { - // `auto` is an alias of `modern` (the copy says so, test/endpoint-copy); - // `legacy` pins the older single base per plane, which is the whole point - // of the setting — the two families are separately entitled. - expect(baseForProtocol("opencode", "openai-completions", "auto")).toBe( - OPENCODE_PLANE_API.zen - ); - expect(baseForProtocol("opencode-go", "openai-completions", "auto")).toBe( - OPENCODE_PLANE_API.go - ); - - expect(baseForProtocol("opencode", "openai-completions", "legacy")).toBe( - "https://opencode.ai/zen/v1" - ); - expect(baseForProtocol("opencode-go", "openai-completions", "legacy")).toBe( - "https://opencode.ai/zen/go/v1" - ); - - expect(baseForProtocol("opencode", "openai-completions", "modern")).toBe( - OPENCODE_PLANE_API.zen - ); - expect(baseForProtocol("opencode-go", "openai-completions", "modern")).toBe( - OPENCODE_PLANE_API.go - ); - }); - - it("resolves auto exactly as modern on every protocol and plane", () => { - for (const plane of ["opencode", "opencode-go"] as const) { - expect(planeBaseFor(plane, "auto")).toBe(planeBaseFor(plane, "modern")); - const protocols = Object.keys(BASE_FOR_PROTOCOL[plane]); - expect(protocols.length).toBe(4); - for (const protocol of protocols) { - expect(baseForProtocol(plane, protocol, "auto"), protocol).toBe( - baseForProtocol(plane, protocol, "modern") - ); - } - } - }); - - it("keeps the Anthropic base one level shorter on every family", () => { - for (const family of ENDPOINT_FAMILIES) { - for (const plane of ["opencode", "opencode-go"] as const) { - const base = baseForProtocol(plane, "anthropic-messages", family); - expect(base?.endsWith("/v1"), `${plane}/${family}`).toBe(false); - expect(base?.endsWith("/v1/messages"), `${plane}/${family}`).toBe( - false - ); - } - } - }); - - it("returns undefined for a plane the table does not carry", () => { - expect( - baseForProtocol("nope", "openai-completions", "modern") - ).toBeUndefined(); - }); - - it("hands the model the base its family names", () => { - const model = { - context_window: 1000, - id: "x", - input_modalities: ["text"], - max_output_tokens: 100, - name: "X", - }; - expect( - piModelFor("opencode", model, "openai-completions", {}, "legacy").baseUrl - ).toBe("https://opencode.ai/zen/v1"); - expect( - piModelFor("opencode", model, "openai-completions", {}, "modern").baseUrl - ).toBe(OPENCODE_PLANE_API.zen); - // No family named means the default, auto, which resolves as modern. - expect( - piModelFor("opencode", model, "openai-completions", {}).baseUrl - ).toBe(OPENCODE_PLANE_API.zen); - }); -}); - -describe("pi-provider · the User-Agent the stream hook sends", () => { - // 1.19.4's client sends `opencode/` and nothing more - - // `packages/opencode/src/session/llm/request.ts:18`. The AI SDK stamps its own - // `ai-sdk/provider-utils/… runtime/bun/…` tail on a request it sends, which is - // a different transport's signature, not the client's; and 2.x changed the - // shape again to `opencode///`. - it("is the CLI's version and nothing else", () => { - expect(OPENCODE_USER_AGENT).toBe("opencode/1.19.4"); - }); -}); - -describe("pi-provider · the object pi requires", () => { - // pi's `Model` declares `reasoning: boolean` and `thinkingLevelMap?`, and a - // model built here may have no entry in pi's installed catalog at all - the - // spread that was supposed to carry those fields is then an empty object. So - // the fields pi requires have to be set, not inherited. - const requiredSpec = { - context_window: 1_048_576, - cost: { input: 1, output: 2 }, - id: "x", - input_modalities: ["text"], - max_output_tokens: 65_536, - name: "X", - reasoning: true, - reasoning_options: [{ type: "effort", values: ["low", "high"] }], - }; - - it("sets every required field, with no catalog entry to inherit from", () => { - const model = piModelFor( - "opencode", - requiredSpec as never, - "openai-completions" - ); - for (const field of [ - "api", - "baseUrl", - "contextWindow", - "cost", - "id", - "input", - "maxTokens", - "name", - "provider", - "reasoning", - ]) { - expect(model, field).toHaveProperty(field); - } - expect(typeof model.reasoning).toBe("boolean"); - }); - - it("says false rather than nothing when a model declares no reasoning", () => { - const model = piModelFor( - "opencode", - { - ...requiredSpec, - reasoning: undefined, - reasoning_options: undefined, - } as never, - "openai-completions" - ); - expect(model.reasoning).toBe(false); - expect(model.thinkingLevelMap).toBeUndefined(); - }); - - it("provides standard thinking levels for models declaring reasoning: true with empty options", () => { - const map = thinkingLevelMapFor({ - context_window: 204_800, - id: "minimax-m2.7", - input_modalities: ["text"], - max_output_tokens: 131_072, - name: "MiniMax-M2.7", - reasoning: true, - reasoning_options: [], - }); - expect(map).toEqual({ - off: null, - minimal: null, - low: "low", - medium: "medium", - high: "high", - xhigh: null, - max: null, - }); - }); - - it("provides toggle mapping for models with toggle reasoning option", () => { - const map = thinkingLevelMapFor({ - context_window: 1_000_000, - id: "minimax-m3", - input_modalities: ["text", "image"], - max_output_tokens: 131_072, - name: "MiniMax-M3", - reasoning: true, - reasoning_options: [{ type: "toggle" }], - }); - expect(map?.off).toBeNull(); - expect(map?.high).toBe("on"); - }); -}); diff --git a/test/picker-integration.test.ts b/test/picker-integration.test.ts deleted file mode 100644 index 73731aaa..00000000 --- a/test/picker-integration.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * The picker, assembled the way the Host assembles it. - * - * Every other suite tests one decorator. This one runs the decorators TOGETHER and - * then builds a model list the way `buildModelCatalog` does, because the bugs that - * cost the most were not in any single decorator — they were in the seam. - * - * The Host's shape is copied from - * `@deepseek-ai/dsh-api-session-controller/lib/types/catalog.js`, which is not a - * dependency of this package, so it is reproduced rather than imported: - * - * ```js - * const providers = ctx.llm.listProviders(); - * const catalog = await Promise.all(providers.map(async (provider) => { - * const models = await ctx.llm.listModels(provider.id); - * const entries = await Promise.all(models.map(async (model) => { - * const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id); - * return { id: model.id, name: model.name, reasoning: resolved.reasoning }; - * })); - * return { kind: 'group', group: {...}, entries }; - * })); - * ``` - * - * **The property under test is that the deployment's list is published exactly.** - * A revision merged the plane's catalog into `listModels`, which made models the - * operator never declared appear in the picker — invisible on screen, and it - * overrode every explicit `models:` array. Nothing here may widen a list. - * - * @module test/picker-integration.test - */ - -import { describe, expect, it } from "vitest"; - -import { - type CordisContext, - decorateModelInfo, - hideResponsesRoute, - resolveConfig, -} from "./support/src.ts"; - -/** What a deployment declared for its parent route. Three ids, no catalog backfill. */ -const DECLARED = [ - { id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash" }, - // A Responses-protocol id and a Messages-protocol id, so the list also proves a - // declared model keeps working when the plugin has to redirect it. - { id: "gpt-5.4", name: "GPT-5.4" }, - { id: "claude-haiku-5-5", name: "Claude Haiku 5.5" }, -]; - -/** A catalog id that was NOT declared, used to prove it never appears. */ -const UNDECLARED = "claude-sonnet-4-5"; - -/** - * A Host `llm` registry shaped like the real one. - * - * `resolveModelInfo` refuses any id the adapter does not carry, which is what an - * adapter backed by an installed catalog does. - */ -const hostRegistry = (declare: readonly { id: string; name: string }[]) => { - const adapters = new Map([ - ["opencode", { adapter: {} }], - ["opencode-go", { adapter: {} }], - ["opencode-responses", { adapter: {} }], - ["opencode-anthropic", { adapter: {} }], - ["opencode-google", { adapter: {} }], - ["opencode-go-responses", { adapter: {} }], - ["opencode-go-anthropic", { adapter: {} }], - ]); - const known = new Set(declare.map((model) => model.id)); - return { - adapters, - listConfigurableProviders: () => [{ provider: "opencode" }], - listModels: async (provider: string) => - provider === "opencode" ? declare : [], - listProviders: () => [...adapters.keys()].map((id) => ({ id, name: id })), - resolveModelInfo: async (provider: string, model: string) => { - if (!known.has(model)) { - throw new Error(`unknown model ${model}`); - } - return { id: model, name: model, provider }; - }, - }; -}; - -/** The Host's `buildModelCatalog`, minus the parts this package does not affect. */ -const buildModelCatalog = async (llm: { - listProviders: () => { id: string; name: string }[]; - listModels: (provider: string) => Promise<{ id: string; name: string }[]>; - resolveModelInfo: (provider: string, model: string) => Promise; -}) => { - const providers = llm.listProviders(); - return Promise.all( - providers.map(async (provider) => { - try { - const models = await llm.listModels(provider.id); - const entries = await Promise.all( - models.map(async (model) => { - const resolved = (await llm.resolveModelInfo( - provider.id, - model.id - )) as { reasoning?: unknown }; - // The Host reads this immediately. An `undefined` answer is a - // TypeError here, and the catch below turns it into a lost group. - return { - id: model.id, - name: model.name, - reasoning: resolved.reasoning, - }; - }) - ); - return { entries, group: provider.id, kind: "group" as const }; - } catch (error) { - return { - error: String(error), - group: provider.id, - kind: "failure" as const, - }; - } - }) - ); -}; - -describe("picker integration: the Host's own assembly", () => { - const config = resolveConfig({ enrichModels: true }); - - it("publishes exactly the declared models, with no failed group", async () => { - const registry = hostRegistry(DECLARED); - const ctx = { llm: registry } as never as CordisContext; - - const stopHide = hideResponsesRoute(ctx, config); - const stopInfo = decorateModelInfo(ctx, config); - try { - const groups = await buildModelCatalog( - registry as never as Parameters[0] - ); - - // Nothing failed. A `failure` here is the whole route vanishing from the - // composer, which is what one unresolvable model used to cause. - expect(groups.filter((group) => group.kind === "failure")).toEqual([]); - - const opencode = groups.find((group) => group.group === "opencode"); - if (opencode?.kind !== "group") { - throw new TypeError("opencode group missing"); - } - // **The declaration, verbatim.** Not the catalog, not a superset. - expect(opencode.entries.map((entry) => entry.id).toSorted()).toEqual( - DECLARED.map((model) => model.id).toSorted() - ); - // And a servable, catalogued model the operator did not declare stays out. - expect(opencode.entries.map((entry) => entry.id)).not.toContain( - UNDECLARED - ); - - // The internal routes themselves stay out of the picker. - const seen = groups.map((group) => group.group); - expect(seen).not.toContain("opencode-responses"); - expect(seen).not.toContain("opencode-anthropic"); - expect(seen).not.toContain("opencode-google"); - } finally { - stopInfo?.(); - stopHide?.(); - } - }); - - it("keeps a group alive when the adapter cannot resolve a DECLARED model", async () => { - // The other failure mode at this seam: one throw inside the group's - // `Promise.all` removes every model on the route, because the Host catches per - // PROVIDER. A declared model the adapter's installed catalog does not carry is - // answered from our catalog instead. - const registry = hostRegistry(DECLARED); - registry.resolveModelInfo = async () => { - throw new Error("unknown model"); - }; - const ctx = { llm: registry } as never as CordisContext; - - const stopHide = hideResponsesRoute(ctx, config); - const stopInfo = decorateModelInfo(ctx, config); - try { - const groups = await buildModelCatalog( - registry as never as Parameters[0] - ); - expect(groups.filter((group) => group.kind === "failure")).toEqual([]); - - const opencode = groups.find((group) => group.group === "opencode"); - if (opencode?.kind !== "group") { - throw new TypeError("opencode group missing"); - } - // Still the declaration, and every row survived the refusal. - expect(opencode.entries).toHaveLength(DECLARED.length); - expect(opencode.entries.map((entry) => entry.id).toSorted()).toEqual( - DECLARED.map((model) => model.id).toSorted() - ); - } finally { - stopInfo?.(); - stopHide?.(); - } - }); -}); diff --git a/test/plane-model.test.ts b/test/plane-model.test.ts new file mode 100644 index 00000000..eeb3660b --- /dev/null +++ b/test/plane-model.test.ts @@ -0,0 +1,622 @@ +/** + * `planeModelFor`: one row per field × a known or gap model × the `modern` or + * `legacy` family × a vendor or foreign route base (`src/utils/plane-model.ts`). + * + * The specs are the bundled catalog's and the pi entries pi 1.1.0's own, so a + * row here is the model the plane adapter actually serves. + * + * @module test/plane-model.test + */ +import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; +import { describe, expect, it } from "vitest"; + +import { + baseUrlFor, + type CatalogModelSpec, + OPENCODE_FAMILY_API, + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, + type PlaneModelInput, + type PlanePiModel, + planeModelFor, + PROTOCOLS, +} from "./support/src.ts"; + +type Plane = "opencode" | "opencode-go"; + +const specOf = (plane: Plane, id: string): CatalogModelSpec | undefined => + (plane === "opencode" ? OPENCODE_ZEN_CATALOG : OPENCODE_GO_CATALOG).find( + (model) => model.id === id + ); + +const piOf = (plane: Plane, id: string): PlanePiModel | undefined => + (getBuiltinModels(plane as never) as unknown as PlanePiModel[]).find( + (model) => model.id === id + ); + +const DEFAULTS = { contextWindow: 262_144, input: ["text"], maxTokens: 32_768 }; + +/** The input a plane builds for one declared id, with overrides. */ +const inputFor = ( + plane: Plane, + id: string, + extra: Partial = {} +): PlaneModelInput => { + const spec = specOf(plane, id); + const pi = piOf(plane, id); + return { + defaults: DEFAULTS, + endpoint: "modern", + entry: { id }, + familyApi: + plane === "opencode" ? OPENCODE_FAMILY_API.zen : OPENCODE_FAMILY_API.go, + route: plane, + ...(spec === undefined ? {} : { spec }), + ...(pi === undefined ? {} : { pi }), + ...extra, + }; +}; + +const modelOf = (input: PlaneModelInput) => { + const result = planeModelFor(input); + if (result.model === undefined) { + throw new Error(`no model: ${result.error}`); + } + return result.model; +}; + +describe("plane-model · protocol (D16)", () => { + it("comes from the catalog SDK, then pi's entry, then the route's api for an id nobody classifies, then completions", () => { + expect(modelOf(inputFor("opencode", "gpt-5.4")).api).toBe( + "openai-responses" + ); + expect(modelOf(inputFor("opencode", "deepseek-v4-pro")).api).toBe( + "openai-completions" + ); + expect(modelOf(inputFor("opencode", "mistral-large-4")).api).toBe( + "openai-completions" + ); + const gemini = inputFor("opencode-go", "gemini-4-argon"); + expect(gemini.pi).toBeUndefined(); + expect(modelOf(gemini).api).toBe("google-generative-ai"); + expect(modelOf(inputFor("opencode", "not-a-real-model")).api).toBe( + "openai-completions" + ); + expect( + modelOf( + inputFor("opencode", "not-a-real-model", { + routeApi: "anthropic-messages", + }) + ).api + ).toBe("anthropic-messages"); + // A route-level api never reclassifies an id the catalog describes. + expect( + modelOf( + inputFor("opencode", "gpt-5.4", { routeApi: "anthropic-messages" }) + ).api + ).toBe("openai-responses"); + // No spec, a pi entry: pi's protocol. + expect( + modelOf({ + ...inputFor("opencode", "x"), + pi: { api: "anthropic-messages", id: "x" }, + }).api + ).toBe("anthropic-messages"); + // A pi protocol the plane does not dispatch is not taken. + expect( + modelOf({ + ...inputFor("opencode", "x"), + pi: { api: "mistral-conversations", id: "x" }, + }).api + ).toBe("openai-completions"); + // A spec whose SDK nothing maps falls through to pi. + expect( + modelOf({ + ...inputFor("opencode", "x"), + pi: { api: "openai-responses", id: "x" }, + spec: { + context_window: 1, + id: "x", + input_modalities: ["text"], + max_output_tokens: 1, + name: "x", + provider_npm: "@ai-sdk/cohere", + }, + }).api + ).toBe("openai-responses"); + }); +}); + +describe("plane-model · base (modern, legacy, route-level)", () => { + const modern = { + opencode: { + "anthropic-messages": "https://opencode.ai/inference/anthropic", + "google-generative-ai": "https://opencode.ai/inference/google/v1beta", + "openai-completions": "https://opencode.ai/inference/openai/v1", + "openai-responses": "https://opencode.ai/inference/openai/v1", + }, + "opencode-go": { + "anthropic-messages": "https://opencode.ai/inference/go/anthropic", + "google-generative-ai": "https://opencode.ai/inference/go/google/v1beta", + "openai-completions": "https://opencode.ai/inference/go/openai/v1", + "openai-responses": "https://opencode.ai/inference/go/openai/v1", + }, + } as const; + + it("modern: the per-protocol inference family, Anthropic without /v1, and auto is modern in every cell", () => { + for (const plane of ["opencode", "opencode-go"] as const) { + const family = + plane === "opencode" ? OPENCODE_FAMILY_API.zen : OPENCODE_FAMILY_API.go; + for (const protocol of PROTOCOLS) { + const modernBase = baseUrlFor( + plane, + protocol, + undefined, + "modern", + family + ); + expect(modernBase, `${plane} ${protocol}`).toBe( + modern[plane][protocol] + ); + expect(baseUrlFor(plane, protocol, undefined, "auto", family)).toBe( + modernBase + ); + // A family map that names nothing falls back to the measured table. + expect(baseUrlFor(plane, protocol, undefined, "modern", {})).toBe( + modernBase + ); + } + } + // The model's own SDK key wins, as the catalog files it. + expect( + baseUrlFor( + "opencode", + "openai-completions", + "@ai-sdk/mistral", + "modern", + { + "": "https://a.example/v1", + "@ai-sdk/mistral": "https://m.example/v1", + } + ) + ).toBe("https://m.example/v1"); + }); + + it("legacy: pi's own /zen base when pi files the model under the protocol, else the fixed table, Go's Google on its one base", () => { + const legacy = { endpoint: "legacy" as const }; + expect(piOf("opencode", "claude-haiku-5-5")).toMatchObject({ + api: "anthropic-messages", + baseUrl: "https://opencode.ai/zen", + }); + expect( + modelOf(inputFor("opencode", "claude-haiku-5-5", legacy)).baseUrl + ).toBe("https://opencode.ai/zen"); + expect( + modelOf(inputFor("opencode", "deepseek-v4-pro", legacy)).baseUrl + ).toBe("https://opencode.ai/zen/v1"); + expect(piOf("opencode", "gpt-5.3-codex-spark")).toBeUndefined(); + expect( + modelOf(inputFor("opencode", "gpt-5.3-codex-spark", legacy)).baseUrl + ).toBe("https://opencode.ai/zen/v1"); + expect( + modelOf(inputFor("opencode-go", "gemini-4-argon", legacy)).baseUrl + ).toBe("https://opencode.ai/inference/go/google/v1beta"); + expect( + modelOf(inputFor("opencode-go", "minimax-m3.1-flash", legacy)).baseUrl + ).toBe("https://opencode.ai/zen/go/v1"); + expect( + modelOf({ + ...inputFor("opencode-go", "gap-claude", legacy), + routeApi: "anthropic-messages", + }).baseUrl + ).toBe("https://opencode.ai/zen/go"); + // pi on a modern base, or under another protocol, is not taken. + expect( + baseUrlFor( + "opencode", + "openai-completions", + undefined, + "legacy", + {}, + undefined, + { + api: "openai-completions", + baseUrl: "https://opencode.ai/inference/openai/v1", + id: "x", + } + ) + ).toBe("https://opencode.ai/zen/v1"); + expect( + baseUrlFor( + "opencode", + "openai-responses", + undefined, + "legacy", + {}, + undefined, + { + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/beta/v1", + id: "x", + } + ) + ).toBe("https://opencode.ai/zen/v1"); + // Taken: pi files the model under the same protocol on a /zen root that + // is NOT the fixed table's, and that root is what is answered — the one + // case where pi's own base and the table disagree. + expect( + baseUrlFor( + "opencode", + "openai-completions", + undefined, + "legacy", + {}, + undefined, + { + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/beta/v1", + id: "x", + } + ) + ).toBe("https://opencode.ai/zen/beta/v1"); + }); + + it("a route-level base: a vendor root selects its family, a foreign root is used as written (Anthropic without /v1)", () => { + const family = OPENCODE_FAMILY_API.zen; + for (const protocol of PROTOCOLS) { + expect( + baseUrlFor( + "opencode", + protocol, + undefined, + "modern", + family, + "https://opencode.ai/zen/v1" + ) + ).toBe( + { + "anthropic-messages": "https://opencode.ai/zen", + "google-generative-ai": "https://opencode.ai/zen/v1", + "openai-completions": "https://opencode.ai/zen/v1", + "openai-responses": "https://opencode.ai/zen/v1", + }[protocol] + ); + expect( + baseUrlFor( + "opencode", + protocol, + undefined, + "legacy", + family, + "https://opencode.ai/inference/v1" + ) + ).toBe(modern.opencode[protocol]); + expect( + baseUrlFor( + "opencode", + protocol, + undefined, + "modern", + family, + "https://relay.example/llm" + ) + ).toBe("https://relay.example/llm"); + } + expect( + baseUrlFor( + "opencode", + "openai-completions", + undefined, + "modern", + family, + "https://relay.example/v1/" + ) + ).toBe("https://relay.example/v1"); + expect( + baseUrlFor( + "opencode", + "anthropic-messages", + undefined, + "modern", + family, + "https://relay.example/v1/" + ) + ).toBe("https://relay.example"); + // Another opencode.ai path is a foreign root; a malformed one is too. + expect( + baseUrlFor( + "opencode", + "openai-completions", + undefined, + "modern", + family, + "https://opencode.ai/custom" + ) + ).toBe("https://opencode.ai/custom"); + expect( + baseUrlFor( + "opencode", + "openai-completions", + undefined, + "modern", + family, + "not a url" + ) + ).toBe("not a url"); + // An empty route base states nothing. + expect( + baseUrlFor( + "opencode", + "openai-completions", + undefined, + "legacy", + family, + "" + ) + ).toBe("https://opencode.ai/zen/v1"); + }); +}); + +describe("plane-model · metadata (D17) and the model object", () => { + it("takes the user's row, then the override, then the catalog, then pi, then the defaults", () => { + const spark = modelOf( + inputFor("opencode", "gpt-5.3-codex-spark", { + entry: { + contextWindow: 400_000, + id: "gpt-5.3-codex-spark", + name: "My Spark", + }, + }) + ); + expect(spark.contextWindow).toBe(400_000); + expect(spark.name).toBe("My Spark"); + const overridden = planeModelFor( + inputFor("opencode", "gpt-5.3-codex-spark", { + override: { contextWindow: 300_000, maxTokens: 9 }, + }) + ); + expect(overridden.model?.contextWindow).toBe(300_000); + expect(overridden.configuredMaxTokens).toBe(9); + const catalog = planeModelFor(inputFor("opencode", "gpt-5.3-codex-spark")); + expect(catalog.model?.contextWindow).toBe(128_000); + expect(catalog.configuredMaxTokens).toBeUndefined(); + expect(catalog.model?.cost).toEqual({ + cacheRead: 0.175, + cacheWrite: 0, + input: 1.75, + output: 14, + }); + const fromPi = modelOf({ + ...inputFor("opencode", "x"), + pi: { + contextWindow: 77, + cost: { cacheRead: 1, cacheWrite: 1, input: 1, output: 1 }, + id: "x", + input: ["text", "image"], + maxTokens: 7, + name: "Pi X", + }, + }); + expect(fromPi).toMatchObject({ + contextWindow: 77, + cost: { input: 1 }, + input: ["text", "image"], + maxTokens: 7, + name: "Pi X", + }); + const bare = modelOf(inputFor("opencode", "x")); + expect(bare).toMatchObject({ + contextWindow: 262_144, + cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 }, + input: ["text"], + maxTokens: 32_768, + name: "x", + provider: "opencode", + reasoning: false, + }); + expect(bare).not.toHaveProperty("thinkingLevelMap"); + const rowed = planeModelFor( + inputFor("opencode", "deepseek-v4-pro", { + entry: { id: "deepseek-v4-pro", input: ["image"], maxTokens: 5 }, + }) + ); + expect(rowed.configuredMaxTokens).toBe(5); + expect(rowed.model?.input).toEqual(["image"]); + // An empty modality list states nothing; the catalog's applies. + expect( + modelOf( + inputFor("opencode", "gpt-5.4", { entry: { id: "gpt-5.4", input: [] } }) + ).input + ).toEqual(["text", "image"]); + expect( + modelOf(inputFor("opencode", "x", { override: { input: ["image"] } })) + .input + ).toEqual(["image"]); + expect( + modelOf( + inputFor("opencode", "x", { + spec: { + context_window: 1, + id: "x", + input_modalities: ["audio"], + max_output_tokens: 1, + name: "x", + }, + }) + ).input + ).toEqual(["text"]); + expect( + modelOf(inputFor("opencode", "x", { override: { name: "Ov" } })).name + ).toBe("Ov"); + // A name that is not a string is no name; a cost the catalog leaves out is zero. + const unnamed = modelOf( + inputFor("opencode", "x", { + entry: { id: "x", name: 5 }, + spec: { + context_window: 1, + cost: {}, + id: "x", + input_modalities: ["text"], + max_output_tokens: 1, + name: "", + }, + }) + ); + expect(unnamed.name).toBe(""); + expect(unnamed.cost).toEqual({ + cacheRead: 0, + cacheWrite: 0, + input: 0, + output: 0, + }); + }); + + it("reasoning: the user's dict, the override's, the catalog's (with D1 names), pi's, else none", () => { + const user = modelOf( + inputFor("opencode", "gpt-5.4", { + entry: { id: "gpt-5.4", reasoningEfforts: { high: "hi" } }, + }) + ); + expect(user.thinkingLevelMap).toMatchObject({ high: "hi", low: null }); + // A value in the dict that is neither a wire string nor null is no value. + expect( + modelOf( + inputFor("opencode", "gpt-5.4", { + entry: { id: "gpt-5.4", reasoningEfforts: { high: "hi", low: 5 } }, + }) + ).thinkingLevelMap + ).toEqual(user.thinkingLevelMap); + expect( + modelOf( + inputFor("opencode", "gpt-5.4", { + entry: { id: "gpt-5.4", reasoningEfforts: false }, + }) + ).reasoning + ).toBe(false); + expect( + modelOf( + inputFor("opencode", "gpt-5.4", { + override: { reasoningEfforts: { low: "l" } }, + }) + ).thinkingLevelMap + ).toMatchObject({ low: "l" }); + expect( + planeModelFor( + inputFor("opencode", "gpt-5.4", { + entry: { id: "gpt-5.4", reasoningEfforts: "bad" }, + }) + ).error + ).toMatch(/has an empty reasoningEfforts/u); + const toggle = planeModelFor(inputFor("opencode-go", "minimax-m3")); + expect(toggle.effortNames).toEqual({ high: "On", off: "Off" }); + expect(toggle.model?.thinkingLevelMap).not.toHaveProperty("off"); + // A completions model whose effort list names none serves the map pi + // 1.1.0's own entry carries: Off offered, with none as its wire value. + for (const [plane, id] of [ + ["opencode", "mistral-large-4"], + ["opencode-go", "hy3"], + ["opencode-go", "hy4-preview"], + ] as const) { + const pi = piOf(plane, id); + expect(pi?.thinkingLevelMap?.off, `${plane}/${id}`).toBe("none"); + expect( + modelOf(inputFor(plane, id)).thinkingLevelMap, + `${plane}/${id}` + ).toEqual(pi?.thinkingLevelMap); + } + const fromPi = modelOf({ + ...inputFor("opencode", "x"), + pi: { id: "x", reasoning: true, thinkingLevelMap: { high: "h" } }, + }); + expect(fromPi).toMatchObject({ + reasoning: true, + thinkingLevelMap: { high: "h" }, + }); + }); + + it("compat: pi's block only when pi files the model under the same protocol; route switches the protocol takes; a model switch it does not take is pi-ai's error", () => { + const piCompat = { thinkingFormat: "deepseek" }; + const pi = { api: "openai-completions", compat: piCompat, id: "x" }; + expect(modelOf({ ...inputFor("opencode", "x"), pi }).compat).toEqual( + piCompat + ); + expect( + modelOf({ + ...inputFor("opencode", "x"), + pi, + spec: { + context_window: 1, + id: "x", + input_modalities: ["text"], + max_output_tokens: 1, + name: "x", + provider_npm: "@ai-sdk/openai", + }, + }) + ).not.toHaveProperty("compat"); + expect( + modelOf({ + ...inputFor("opencode", "x"), + pi, + routeCompat: { + chatTemplateArgs: {}, + supportsStore: true, + supportsTemperature: true, + }, + }).compat + ).toEqual({ ...piCompat, supportsStore: true }); + expect( + modelOf( + inputFor("opencode", "gpt-5.4", { + entry: { compat: { supportsStrictMode: true }, id: "gpt-5.4" }, + }) + ).compat + ).toEqual({ + ...piOf("opencode", "gpt-5.4")?.compat, + supportsStrictMode: true, + }); + expect( + planeModelFor( + inputFor("opencode", "gpt-5.4", { + entry: { compat: { thinkingFormat: "deepseek" }, id: "gpt-5.4" }, + }) + ).error + ).toBe( + 'llm-pi-ai: provider "opencode" model "gpt-5.4" sets compat "thinkingFormat", but its api is "openai-responses", which does not take it; that switch exists on openai-completions, and "openai-responses" offers supportsDeveloperRole, supportsMaxOutputTokens, supportsStrictMode, supportsLongCacheRetention' + ); + expect( + planeModelFor( + inputFor("opencode", "gemini-3-flash", { + entry: { compat: { supportsStore: true }, id: "gemini-3-flash" }, + }) + ).error + ).toMatch(/offers no configurable compat$/u); + }); + + it("validates sizes in pi-ai's words, names the route on the model, and never throws on garbage", () => { + expect(modelOf(inputFor("opencode", "deepseek-v4-pro")).provider).toBe( + "opencode" + ); + expect( + planeModelFor( + inputFor("opencode", "x", { entry: { contextWindow: 0, id: "x" } }) + ).error + ).toBe( + 'llm-pi-ai: provider "opencode" model "x" contextWindow must be a positive integer' + ); + expect( + planeModelFor( + inputFor("opencode", "x", { entry: { id: "x", maxTokens: 1.5 } }) + ).error + ).toMatch(/maxTokens must be a positive integer/u); + let result: ReturnType | undefined; + expect(() => { + result = planeModelFor({ + ...inputFor("opencode", "x"), + entry: { id: 5 }, + }); + }).not.toThrow(); + expect(result?.error).toMatch(/id is not a string/u); + expect(result?.model).toBeUndefined(); + }); +}); diff --git a/test/plane-profile.test.ts b/test/plane-profile.test.ts new file mode 100644 index 00000000..2e1b0596 --- /dev/null +++ b/test/plane-profile.test.ts @@ -0,0 +1,555 @@ +import { Context } from "@deepseek-ai/cordis"; +/** + * `planeProfileFor`: a plane's resolved profile from its raw entry, as pi-ai + * resolves any route, minus "needs an api" (`src/utils/plane-profile.ts`). + * + * The host pieces are the real ones — pi-ai's `Config`, dsh-llm's + * `resolveRetryPolicy`, pi's `createProvider` and protocol factories — so a + * profile here is the one the plane adapter reads. + * + * @module test/plane-profile.test + */ +import { interpolate } from "@deepseek-ai/cordis-plugin-loader"; +import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm"; +import * as LlmPiAi from "@deepseek-ai/dsh-llm-pi-ai"; +import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy"; +import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; +import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; +import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; +import { createProvider } from "@earendil-works/pi-ai/models"; +import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; +import { describe, expect, it } from "vitest"; + +import { deepFreeze } from "./support/host.ts"; +import { + hasPlaneEntries, + isRecord, + OPENCODE_FAMILY_API, + OPENCODE_ZEN_CATALOG, + type PlaneProfileInput, + type PlanePiModel, + planeEntriesOf, + planeProfileFor, + withoutPlaneProviders, +} from "./support/src.ts"; + +const normalize = (entry: unknown): unknown => + ( + LlmPiAi.Config({ providers: { opencode: entry } } as never) as unknown as { + providers: { get: () => Record }; + } + ).providers.get().opencode; + +const buildProvider: PlaneProfileInput["buildProvider"] = ({ + id, + models, + name, +}) => + createProvider({ + api: { + "anthropic-messages": anthropicMessagesApi(), + "google-generative-ai": googleGenerativeAIApi(), + "openai-completions": openAICompletionsApi(), + "openai-responses": openAIResponsesApi(), + }, + auth: {}, + id, + models: models as never, + name, + } as never); + +const input = ( + entry: unknown, + extra: Partial = {} +): PlaneProfileInput => ({ + buildProvider, + catalog: OPENCODE_ZEN_CATALOG, + endpoint: "auto", + entry, + familyApi: OPENCODE_FAMILY_API.zen, + normalize, + piModels: getBuiltinModels("opencode" as never) as unknown as PlanePiModel[], + resolveRetryPolicy: + resolveRetryPolicy as PlaneProfileInput["resolveRetryPolicy"], + route: "opencode", + ...extra, +}); + +/** The ids the profile's provider serves. */ +const served = (result: ReturnType): string[] => + (result.profile.piProvider as { getModels: () => { id: string }[] }) + .getModels() + .map((model) => model.id); + +describe("plane-profile · the field mapping and pi-ai's defaults", () => { + it("maps a minimal entry onto every field pi-ai resolves, with its defaults", () => { + const result = planeProfileFor( + input({ + apiKeyEnv: "OPENCODE_API_KEY", + models: [{ id: "deepseek-v4-pro" }], + }) + ); + const { profile } = result; + // host-shapes' runtime key list, minus catalogError (nothing failed). + expect(Object.keys(profile).toSorted()).toEqual([ + "apiKeyEnv", + "compat", + "configuredMaxTokens", + "defaultContextWindow", + "defaultInput", + "defaultMaxTokens", + "displayName", + "headers", + "maxRequestImageBytes", + "modelErrors", + "modelOverrides", + "piProvider", + "provider", + "requestImageMaxBytes", + "requestImagePixelBudget", + "retryPolicy", + "streamIdleTimeoutMs", + "thinkingBudgets", + ]); + expect(profile.displayName).toBe("opencode"); + expect(profile.streamIdleTimeoutMs).toBe(300_000); + expect(profile.retryPolicy).toEqual(resolveRetryPolicy(undefined, "t")); + expect(profile.modelErrors.size).toBe(0); + expect( + ( + profile.piProvider as { getModels: () => Record[] } + ).getModels()[0] + ).toMatchObject({ + api: "openai-completions", + baseUrl: "https://opencode.ai/inference/openai/v1", + id: "deepseek-v4-pro", + provider: "opencode", + }); + expect(result.normalized).toMatchObject({ apiKeyEnv: "OPENCODE_API_KEY" }); + }); + + it("D3: no models list, or an empty one, serves the plugin's servable catalog in catalog order", () => { + const catalogIds = OPENCODE_ZEN_CATALOG.map((spec) => spec.id); + expect(served(planeProfileFor(input({})))).toEqual(catalogIds); + expect(served(planeProfileFor(input({ models: [] })))).toEqual(catalogIds); + // A catalog SDK nothing serves is left out. + const withUnserved = planeProfileFor( + input( + {}, + { + catalog: [ + ...OPENCODE_ZEN_CATALOG.slice(0, 1), + { + context_window: 1, + id: "cohere-x", + input_modalities: ["text"], + max_output_tokens: 1, + name: "x", + provider_npm: "@ai-sdk/cohere", + }, + ], + } + ) + ); + expect(served(withUnserved)).toEqual([OPENCODE_ZEN_CATALOG[0]?.id]); + // modelOverrides apply to the catalog list, and configuredMaxTokens follows. + const overridden = planeProfileFor( + input({ modelOverrides: { "gpt-5.4": { maxTokens: 7, name: "G" } } }) + ); + expect(overridden.profile.configuredMaxTokens.get("gpt-5.4")).toBe(7); + expect(overridden.effortNames.get("gpt-5.4")).toMatchObject({ low: "Low" }); + }); + + it("D15: an id no catalog describes is served, with no model error", () => { + const result = planeProfileFor( + input({ models: [{ id: "not-a-real-model" }] }) + ); + expect(result.profile.modelErrors.size).toBe(0); + expect(result.profile.catalogError).toBeUndefined(); + expect(served(result)).toEqual(["not-a-real-model"]); + }); + + it("hands the route-level api, base and compat to each model: api only for ids nobody classifies, the base shaped per protocol", () => { + const result = planeProfileFor( + input({ + api: "anthropic-messages", + baseURL: "https://relay.example/v1", + compat: { supportsStore: true }, + models: [{ id: "deepseek-v4-pro" }, { id: "unknown-x" }], + }) + ); + expect(result.profile.catalogError).toBeUndefined(); + const models = ( + result.profile.piProvider as { + getModels: () => { + id: string; + api: string; + baseUrl: string; + compat?: object; + }[]; + } + ).getModels(); + expect( + models.map(({ api, baseUrl, id }) => ({ api, baseUrl, id })) + ).toEqual([ + { + api: "openai-completions", + baseUrl: "https://relay.example/v1", + id: "deepseek-v4-pro", + }, + { + api: "anthropic-messages", + baseUrl: "https://relay.example", + id: "unknown-x", + }, + ]); + expect(models[0]?.compat).toMatchObject({ supportsStore: true }); + }); + + it("carries the route's own knobs and nothing else", () => { + const { profile } = planeProfileFor( + input({ + displayName: "Zen", + headers: { "x-a": "1" }, + reasoning: "high", + thinkingBudgets: { high: 9 }, + }) + ); + expect(profile).toMatchObject({ + displayName: "Zen", + headers: { "x-a": "1" }, + reasoning: "high", + thinkingBudgets: { high: 9 }, + }); + }); +}); + +describe("plane-profile · the Google level ids the builder is handed (D16)", () => { + it("names exactly the served Google ids the catalog describes by level, from the catalog, never the id", () => { + const handed: (ReadonlySet | undefined)[] = []; + const recording: PlaneProfileInput["buildProvider"] = (build) => { + handed.push(build.levelThinkingModels); + return buildProvider(build); + }; + // The catalog's Gemini models beside a non-Google one, an id no catalog + // describes (named like a Gemini model), and one that fails. The filter is + // "Google with any effort option", not the rule itself: a deliberate shape + // tripwire beside the model-metadata oracle, so a Google effort list that + // is not all levels fails here rather than being re-derived. + const levelIds = OPENCODE_ZEN_CATALOG.filter( + (spec) => + spec.provider_npm === "@ai-sdk/google" && + spec.reasoning_options?.some((option) => option.type === "effort") + ).map((spec) => spec.id); + expect(levelIds).toContain("gemini-4-argon"); + // Any other level id, picked by shape, serves as the one listed twice. + const duplicate = levelIds.find((id) => id !== "gemini-4-argon"); + expect(duplicate, "the Zen catalog has a second level model").toBeDefined(); + const result = planeProfileFor( + input( + { + models: [ + ...levelIds.map((id) => ({ id })), + { id: "deepseek-v4-pro" }, + { id: "gemini-9-unlisted" }, + // Listed twice: a model error, so not served. + { id: duplicate ?? "" }, + ], + }, + { buildProvider: recording } + ) + ); + expect(result.profile.modelErrors.get(duplicate ?? "")).toMatch( + /more than once/u + ); + expect(handed).toHaveLength(1); + expect([...(handed[0] ?? [])].toSorted()).toEqual( + levelIds.filter((id) => id !== duplicate).toSorted() + ); + // A catalog that describes the same id by another shape names none. + handed.length = 0; + planeProfileFor( + input( + { models: [{ id: "gemini-4-argon" }] }, + { + buildProvider: recording, + catalog: [ + { + id: "gemini-4-argon", + provider_npm: "@ai-sdk/google", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["low", "max"] }], + }, + ], + } + ) + ); + expect([...(handed[0] ?? [])]).toEqual([]); + }); + + it("a Google-served id the catalog does not describe stays off the level set, whatever its name", () => { + // A route-level `api` cannot put an uncatalogued id on Google (pi-ai's own + // check refuses `google-generative-ai` there), so the reachable case is an + // id pi's own model table serves on Google that the catalog lacks: with no + // catalog spec there is nothing to read its thinking control from, so it + // stays on pi's path, Gemini-named or not. + expect( + planeProfileFor( + input({ + api: "google-generative-ai", + models: [{ id: "gemini-9-unlisted" }], + }) + ).profile.catalogError + ).toMatch(/google-generative-ai/u); + const handed: (ReadonlySet | undefined)[] = []; + const recording: PlaneProfileInput["buildProvider"] = (build) => { + handed.push(build.levelThinkingModels); + return buildProvider(build); + }; + const flash = "gemini-3-flash"; + const result = planeProfileFor( + input( + { models: [{ id: flash }, { id: "gemini-4-argon" }] }, + { + buildProvider: recording, + catalog: OPENCODE_ZEN_CATALOG.filter((spec) => spec.id !== flash), + } + ) + ); + expect(result.profile.catalogError).toBeUndefined(); + const models = ( + result.profile.piProvider as { + getModels: () => { id: string; api: string }[]; + } + ).getModels(); + expect(models.map(({ api, id }) => ({ api, id }))).toEqual([ + { api: "google-generative-ai", id: flash }, + { api: "google-generative-ai", id: "gemini-4-argon" }, + ]); + expect(handed).toHaveLength(1); + expect([...(handed[0] ?? [])]).toEqual(["gemini-4-argon"]); + }); +}); + +describe("plane-profile · malformed entries, in pi-ai's words, never thrown", () => { + it("records model failures per model and route failures as catalogError with no provider", () => { + expect( + planeProfileFor(input({ models: [{ id: "" }] })).profile.catalogError + ).toBe('llm-pi-ai: provider "opencode" has a model with an empty id'); + const duplicate = planeProfileFor( + input({ models: [{ id: "x" }, { id: "y" }, { id: "x" }] }) + ); + expect(duplicate.profile.modelErrors.get("x")).toMatch( + /lists model "x" more than once/u + ); + expect(served(duplicate)).toEqual(["y"]); + const header = planeProfileFor(input({ headers: { "bad header": "v" } })); + expect(header.profile.catalogError).toMatch( + /header "bad header" is not valid for Fetch/u + ); + expect(header.profile.piProvider).toBeUndefined(); + expect( + planeProfileFor( + input({ models: [{ id: "m", reasoningEfforts: { high: null } }] }) + ).profile.modelErrors.get("m") + ).toMatch(/reasoningEfforts\.high needs the wire value/u); + expect( + planeProfileFor(input({ maxRetries: 1 })).profile.catalogError + ).toMatch(/maxRetries or maxRetryDelayMs, which were removed/u); + expect( + planeProfileFor(input({ provider: "x" })).profile.catalogError + ).toMatch(/sets "provider"/u); + }); + + it("mirrors the rest of pi-ai's route checks", () => { + const failure = (entry: unknown, extra: Partial = {}) => + planeProfileFor(input(entry, extra)).profile.catalogError; + expect(failure({ baseURL: "" })).toMatch(/has an empty baseURL/u); + expect(failure({ displayName: "" })).toMatch(/has an empty displayName/u); + expect( + failure({ streamIdleTimeoutMs: 2_147_483_648 }, { normalize: undefined }) + ).toMatch( + /streamIdleTimeoutMs must be a positive finite number no greater than 2147483647/u + ); + expect( + failure({ maxRequestImageBytes: 1.5 }, { normalize: undefined }) + ).toMatch(/maxRequestImageBytes must be a positive integer/u); + expect( + failure({ requestImagePixelBudget: 0 }, { normalize: undefined }) + ).toMatch(/requestImagePixelBudget must be a positive safe integer/u); + expect(failure({ defaultInput: [] })).toMatch( + /defaultInput must name at least one modality/u + ); + expect(failure({ apiKeyEnv: "not-a-ref" })).toMatch(/credential ref/u); + expect(failure({ modelOverrides: { "": {} } })).toMatch( + /modelOverrides entry with an empty model id/u + ); + expect( + failure({ models: [{ id: "x" }], modelOverrides: { "gpt-5.4": {} } }) + ).toMatch(/beside a models list/u); + expect( + failure( + { modelOverrides: { "gpt-5.4": { id: "y" } } }, + { normalize: undefined } + ) + ).toMatch(/sets "id", which is the dict key/u); + expect(failure({ modelOverrides: { "no-such": {} } })).toMatch( + /modelOverrides names "no-such", which the installed catalog does not describe/u + ); + expect( + failure({ compat: { openRouterRouting: {} } }, { normalize: undefined }) + ).toMatch(/compat "openRouterRouting", which is not configurable here/u); + expect( + failure({ compat: { noSuch: true } }, { normalize: undefined }) + ).toMatch( + /which no wire protocol declares; the configurable switches are supportsStore/u + ); + expect( + failure({ compat: { supportsStore: null } }, { normalize: undefined }) + ).toMatch(/with no value/u); + expect(failure({ compat: 5 }, { normalize: undefined })).toMatch( + /compat must be a dict/u + ); + expect( + failure({ + compat: { supportsTemperature: true }, + models: [{ id: "deepseek-v4-pro" }], + }) + ).toMatch(/no model on the route speaks a protocol that takes it/u); + expect( + planeProfileFor( + input( + { models: [{ compat: { noSuch: true }, id: "m" }] }, + { normalize: undefined } + ) + ).profile.modelErrors.get("m") + ).toMatch(/model "m" sets compat "noSuch"/u); + expect( + planeProfileFor( + input( + { models: [{ contextWindow: 0, id: "m" }] }, + { normalize: undefined } + ) + ).profile.modelErrors.get("m") + ).toMatch(/contextWindow must be a positive integer/u); + expect(failure({ models: [] }, { catalog: [] })).toMatch( + /resolves no models/u + ); + expect( + failure({ retryPolicy: { mode: "nope" } }, { normalize: undefined }) + ).toBeDefined(); + expect( + planeProfileFor( + input( + {}, + { + buildProvider: () => { + throw new Error("no protocols"); + }, + } + ) + ).profile + ).toMatchObject({ catalogError: "no protocols" }); + }); + + it("never throws, whatever the input, and always says why", () => { + for (const entry of [ + undefined, + null, + 1, + "x", + [], + { models: "nope" }, + { models: [null] }, + { models: [{ id: 1 }] }, + { headers: 5 }, + ]) { + for (const normalizer of [normalize, undefined]) { + let result: ReturnType | undefined; + expect(() => { + result = planeProfileFor(input(entry, { normalize: normalizer })); + }, JSON.stringify(entry)).not.toThrow(); + expect( + result?.profile.catalogError, + JSON.stringify(entry) + ).toBeDefined(); + expect(result?.profile.provider).toBe("opencode"); + } + } + // A normalizer that answers no dict fails the route. + expect( + planeProfileFor(input({}, { normalize: () => 5 })).profile.catalogError + ).toMatch(/must be a dict of settings/u); + }); +}); + +describe("plane-profile · !!js", () => { + it("interpolates first; without a loader a leftover node fails only its model, or the route at route level", () => { + const withLoader = planeProfileFor( + input( + { displayName: { __jsExpr: '"Z"' } }, + { interpolate: (value) => interpolate(new Context(), value) } + ) + ); + expect(withLoader.profile.displayName).toBe("Z"); + expect( + planeProfileFor( + input( + {}, + { + interpolate: () => { + // oxlint-disable-next-line no-throw-literal, typescript/only-throw-error -- a loader may throw anything. + throw "evaluation failed"; + }, + } + ) + ).profile.catalogError + ).toBe("evaluation failed"); + expect(withLoader.profile.catalogError).toBeUndefined(); + const leftover = planeProfileFor( + input({ + models: [ + { id: "m", name: { __jsExpr: '"N"' } }, + { id: "deepseek-v4-pro" }, + ], + }) + ); + expect([...leftover.profile.modelErrors.keys()]).toEqual(["m"]); + expect(served(leftover)).toEqual(["deepseek-v4-pro"]); + expect( + planeProfileFor(input({ displayName: { __jsExpr: '"Z"' } })).profile + .piProvider + ).toBeUndefined(); + expect( + planeProfileFor( + input({ models: [[{ __jsExpr: "1" }]] }) + ).profile.modelErrors.has("") + ).toBe(true); + }); +}); + +describe("plane-profile · withoutPlaneProviders and the plane readers", () => { + it("copies, never touches a frozen input, and keeps every other key", () => { + const raw = deepFreeze({ + other: true, + providers: { deepseek: { a: 1 }, opencode: {}, "opencode-go": {} }, + }); + let copy: unknown; + expect(() => { + copy = withoutPlaneProviders(raw); + }).not.toThrow(); + expect(copy).toEqual({ other: true, providers: { deepseek: { a: 1 } } }); + expect(copy).not.toBe(raw); + expect(isRecord(copy) && copy.providers).not.toBe(raw.providers); + expect(withoutPlaneProviders({ a: 1 })).toEqual({ a: 1 }); + expect(withoutPlaneProviders(5)).toBe(5); + expect(hasPlaneEntries(raw)).toBe(true); + expect(hasPlaneEntries({ providers: { deepseek: {} } })).toBe(false); + expect(hasPlaneEntries(null)).toBe(false); + expect(planeEntriesOf(raw)).toEqual({ opencode: {}, "opencode-go": {} }); + expect(planeEntriesOf({ providers: { opencode: 1 } })).toEqual({ + opencode: 1, + }); + expect(planeEntriesOf(null)).toEqual({}); + }); +}); diff --git a/test/plane-provider.test.ts b/test/plane-provider.test.ts new file mode 100644 index 00000000..533d4d21 --- /dev/null +++ b/test/plane-provider.test.ts @@ -0,0 +1,708 @@ +/** + * The plane adapter and its auth seams, one clause at a time + * (`src/host/plane-provider.ts`). + * + * 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 + * file covers the branches a host composition does not reach. + * + * @module test/plane-provider.test + */ +import { LlmError } from "@deepseek-ai/dsh-llm"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { fakeCredentials, hostModulesForTests } from "./support/host.ts"; +import { + createOpencodeStore, + type FixtureStore, +} from "./support/opencode-store-fixture.ts"; +import { + buildPlaneProvider, + createPlaneAdapter, + type HostLibraries, + type HostModules, + hostError, + OPENCODE_FAMILY_API, + OPENCODE_ZEN_CATALOG, + type PiAiModule, + planeApiKeyResolver, + planeAuth, + planeProfileFor, + 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(); +}); + +const modules = async (): Promise => { + const source = hostModulesForTests(); + const [piAi, libraries] = await Promise.all([ + source.piAi({}), + source.libraries(), + ]); + if (piAi === undefined || libraries === undefined) { + throw new Error("no host modules"); + } + return { libraries, piAi }; +}; + +/** A plane profile over the real pieces. */ +const profileFor = ( + libraries: HostLibraries, + piAi: PiAiModule, + entry: unknown +): Record => + planeProfileFor({ + buildProvider: buildPlaneProvider(libraries), + catalog: OPENCODE_ZEN_CATALOG, + endpoint: "auto", + entry, + familyApi: OPENCODE_FAMILY_API.zen, + normalize: (raw) => + ( + piAi.Config({ providers: { opencode: raw } }) as { + providers: { get: () => Record }; + } + ).providers.get().opencode, + piModels: libraries.builtinModels("opencode"), + resolveRetryPolicy: libraries.resolveRetryPolicy, + route: "opencode", + }).profile; + +/** The adapter, typed as far as the cases call it. */ +interface AdapterLike { + listModels: ( + provider: string + ) => Promise<{ id: string; name: string; inputModalities?: string[] }[]>; + resolveModel: ( + provider: string, + model: string + ) => Promise>; + prepareCall: ( + provider: string, + model: string + ) => Promise>; +} + +const adapterOver = async ( + profiles: ReadonlyMap>, + resolveApiKey: () => Promise, + names?: Readonly> +): Promise => { + const host = await modules(); + return createPlaneAdapter({ + auth: planeAuth({}, host.piAi.recordKeyFor, host.libraries), + effortNames: () => names, + modules: host, + profiles: () => profiles, + resolveApiKey, + }) as unknown as AdapterLike; +}; + +describe("plane-provider · the adapter", () => { + it("is a PlaneAdapter over the host's PiAiAdapter, and lists the declared ids when a key resolves or is missing", async () => { + const { libraries, piAi } = await modules(); + const profiles = new Map([ + [ + "opencode", + profileFor(libraries, piAi, { models: [{ id: "gpt-5.4" }] }), + ], + ]); + const keyed = await adapterOver(profiles, async () => "k"); + expect(keyed.constructor.name).toBe("PlaneAdapter"); + expect(keyed).toBeInstanceOf(piAi.PiAiAdapter); + expect(modelIds(await keyed.listModels("opencode"))).toEqual(["gpt-5.4"]); + const missing = await adapterOver(profiles, async () => { + throw new LlmError("no key", "MISSING_CREDENTIAL"); + }); + expect(await missing.listModels("opencode")).toEqual([ + { + id: "gpt-5.4", + inputModalities: ["text", "image"], + name: "GPT-5.4", + provider: "opencode", + }, + ]); + const broken = await adapterOver(profiles, async () => { + throw new Error("vault down"); + }); + await expect(broken.listModels("opencode")).rejects.toThrow(/vault down/u); + // A route it does not own is pi-ai's own refusal. + await expect(keyed.listModels("elsewhere")).rejects.toMatchObject({ + code: "NO_ADAPTER", + }); + }); + + it("answers what a route without a provider can, and reads odd models defensively", async () => { + const odd = { + getModels: () => [ + { id: "a", input: "text", name: 5 }, + { name: "no id" }, + null, + ], + }; + const profiles = new Map>([ + [ + "opencode", + { + catalogError: "broken", + configuredMaxTokens: new Map(), + displayName: "opencode", + modelErrors: new Map(), + provider: "opencode", + }, + ], + [ + "opencode-go", + { + configuredMaxTokens: new Map(), + displayName: "opencode-go", + modelErrors: new Map(), + piProvider: odd, + provider: "opencode-go", + }, + ], + ]); + const adapter = await adapterOver(profiles, async () => { + throw new LlmError("no key", "MISSING_CREDENTIAL"); + }); + expect(await adapter.listModels("opencode")).toEqual([]); + expect(await adapter.listModels("opencode-go")).toEqual([ + { + id: "a", + inputModalities: ["text"], + name: "a", + provider: "opencode-go", + }, + ]); + const noModels = await adapterOver( + new Map([ + [ + "opencode", + { + configuredMaxTokens: new Map(), + displayName: "opencode", + modelErrors: new Map(), + piProvider: { getModels: () => "nope" }, + provider: "opencode", + }, + ], + ]), + async () => { + throw new LlmError("no key", "MISSING_CREDENTIAL"); + } + ); + expect(await noModels.listModels("opencode")).toEqual([]); + }); + + it("relabels a toggle model's efforts as Off/On in model info and in a prepared call, ids untouched", async () => { + const { libraries } = await modules(); + const goProfile = planeProfileFor({ + buildProvider: buildPlaneProvider(libraries), + catalog: [], + endpoint: "auto", + entry: { + models: [ + { id: "toggle", reasoningEfforts: { high: "high", off: null } }, + ], + }, + familyApi: OPENCODE_FAMILY_API.go, + piModels: [], + resolveRetryPolicy: libraries.resolveRetryPolicy, + route: "opencode-go", + }).profile; + const adapter = await adapterOver( + new Map([["opencode-go", goProfile]]), + async () => "k", + { high: "On", off: "Off" } + ); + const info = await adapter.resolveModel("opencode-go", "toggle"); + expect(info.reasoning).toMatchObject({ + efforts: [ + { id: "off", name: "Off" }, + { id: "high", name: "On" }, + ], + }); + const prepared = await adapter.prepareCall("opencode-go", "toggle"); + expect((prepared.model as { reasoning: unknown }).reasoning).toEqual( + info.reasoning + ); + expect(typeof prepared.stream).toBe("function"); + // Without names the host's own labels stand. + const plain = await adapterOver( + new Map([["opencode-go", goProfile]]), + async () => "k" + ); + const plainInfo = await plain.resolveModel("opencode-go", "toggle"); + expect(plainInfo.reasoning).toMatchObject({ + efforts: [{ name: "Off" }, { name: "High" }], + }); + }); + + it("relabelEfforts leaves anything it does not recognize as it was", () => { + const names = { high: "On" }; + for (const info of [ + null, + 5, + {}, + { reasoning: 5 }, + { reasoning: { efforts: 5 } }, + ]) { + expect(relabelEfforts(info, names)).toBe(info); + } + expect(relabelEfforts({ reasoning: { efforts: [] } })).toEqual({ + reasoning: { efforts: [] }, + }); + expect( + relabelEfforts( + { + reasoning: { efforts: [null, { id: 1 }, { id: "low", name: "Low" }] }, + }, + names + ) + ).toEqual({ + reasoning: { efforts: [null, { id: 1 }, { id: "low", name: "Low" }] }, + }); + }); +}); + +describe("plane-provider · planeAuth (pi-ai's auth.ts, restated)", () => { + const recordKeyFor = (id: string): string => `llm-pi-ai/${id}`; + const withService = (service: unknown) => ({ + get: (name: string) => (name === "credentials" ? service : undefined), + }); + + it("env asks the credentials service for a ref name, then the launch environment", async () => { + const credentials = fakeCredentials({ refs: { OPENCODE_API_KEY: "svc" } }); + const launch = { + get: (name: string) => (name === "OTHER" ? { value: "env" } : undefined), + }; + const services: Record = { + credentials: credentials.service, + launchEnvironment: launch, + }; + const ctx = { get: (name: string) => services[name] }; + const auth = planeAuth(ctx, recordKeyFor); + expect(await auth.authContext.env("OPENCODE_API_KEY")).toBe("svc"); + expect(await auth.authContext.env("OTHER")).toBe("env"); + expect(await auth.authContext.env("not a ref")).toBeUndefined(); + expect(credentials.calls.map(([method]) => method)).toEqual([ + "resolve", + "resolve", + ]); + // A hit without a string value is no value. + const odd = planeAuth( + withService({ resolve: async () => ({ value: 5 }) }), + recordKeyFor + ); + expect(await odd.authContext.env("X")).toBeUndefined(); + // No credentials service and no snapshot: the process environment. + vi.stubEnv("PLANE_AUTH_PROBE", "from-process"); + expect( + await planeAuth({}, recordKeyFor).authContext.env("PLANE_AUTH_PROBE") + ).toBe("from-process"); + expect(await auth.authContext.fileExists("/anything")).toBe(false); + }); + + it("reads records under pi-ai's key, mapping an api-key record and a grant payload", async () => { + const credentials = fakeCredentials({ + records: { + "llm-pi-ai/opencode": { env: { A: "1" }, key: "k", kind: "api-key" }, + "llm-pi-ai/opencode-go": { kind: "grant", payload: { type: "oauth" } }, + "llm-pi-ai/plain": { kind: "api-key" }, + "llm-pi-ai/odd": { kind: "grant", payload: 5 }, + "other-scope/opencode": { kind: "api-key", key: "x" }, + }, + }); + const auth = planeAuth(withService(credentials.service), recordKeyFor); + expect(await auth.credentials.read("opencode")).toEqual({ + env: { A: "1" }, + key: "k", + type: "api_key", + }); + expect(await auth.credentials.read("opencode-go")).toEqual({ + type: "oauth", + }); + expect(await auth.credentials.read("plain")).toEqual({ type: "api_key" }); + expect(await auth.credentials.read("odd")).toBeUndefined(); + expect(await auth.credentials.read("missing")).toBeUndefined(); + expect(await auth.credentials.read("Not Valid")).toBeUndefined(); + expect( + await planeAuth({}, recordKeyFor).credentials.read("opencode") + ).toBeUndefined(); + expect(await auth.credentials.list()).toEqual([ + { providerId: "opencode", type: "api_key" }, + { providerId: "opencode-go", type: "oauth" }, + { providerId: "plain", type: "api_key" }, + { providerId: "odd", type: "oauth" }, + ]); + expect(await planeAuth({}, recordKeyFor).credentials.list()).toEqual([]); + expect( + await planeAuth( + withService({ listRecords: async () => [null, { key: 5 }] }), + recordKeyFor + ).credentials.list() + ).toEqual([]); + }); + + it("modifies and deletes through the service, refusing what pi-ai refuses", async () => { + const credentials = fakeCredentials(); + const libraries = { LlmError } as unknown as HostLibraries; + const auth = planeAuth( + withService(credentials.service), + recordKeyFor, + libraries + ); + expect( + await auth.credentials.modify("opencode", async () => ({ + key: "new", + type: "api_key", + })) + ).toEqual({ key: "new", type: "api_key" }); + expect( + await auth.credentials.modify("opencode-go", async () => ({ + list: [1, undefined], + nested: { gone: undefined, kept: 1 }, + type: "oauth", + })) + ).toEqual({ list: [1, null], nested: { kept: 1 }, type: "oauth" }); + expect( + await auth.credentials.modify("opencode", async () => ({ + env: { B: "2" }, + type: "api_key", + })) + ).toEqual({ env: { B: "2" }, type: "api_key" }); + // A mutation that answers nothing writes nothing. + await auth.credentials.modify("fresh", async (current) => { + expect(current).toBeUndefined(); + }); + await expect( + auth.credentials.modify("Not Valid", async () => {}) + ).rejects.toMatchObject({ code: "UNSTORABLE_PROVIDER_ID" }); + await auth.credentials.delete("opencode"); + await auth.credentials.delete("Not Valid"); + expect( + credentials.calls.filter(([method]) => method === "deleteRecord") + ).toEqual([["deleteRecord", "llm-pi-ai/opencode"]]); + const none = planeAuth({}, recordKeyFor, libraries); + await expect( + none.credentials.modify("opencode", async () => {}) + ).rejects.toMatchObject({ code: "NO_CREDENTIAL_STORE" }); + await expect(none.credentials.delete("opencode")).rejects.toBeInstanceOf( + LlmError + ); + }); + + it("hostError carries the code in dsh-llm's class, or on a plain Error with a frozen failure", () => { + const typed = hostError({ LlmError } as unknown as HostLibraries, "m", "C"); + expect(typed).toBeInstanceOf(LlmError); + expect(typed).toMatchObject({ code: "C", message: "m" }); + const plain = hostError(undefined, "m", "C") as Error & { + code?: string; + failure?: object; + }; + expect(plain).not.toBeInstanceOf(LlmError); + expect(plain.code).toBe("C"); + expect(plain.failure).toEqual({ code: "C", message: "m" }); + expect(Object.isFrozen(plain.failure)).toBe(true); + }); +}); + +describe("plane-provider · planeApiKeyResolver (D20)", () => { + it("defers to pi's own discovery with no ref, resolves a named ref through the service or the launch environment, and fails loudly on a miss", async () => { + const host = await modules(); + const service = fakeCredentials({ refs: { OPENCODE_API_KEY: " svc " } }); + const viaService = planeApiKeyResolver( + { + get: (name: string) => + name === "credentials" ? service.service : undefined, + }, + host, + { readOpencodeStore: false } + ); + expect(await viaService("opencode", {})).toBeUndefined(); + expect( + await viaService("opencode", { apiKeyEnv: "OPENCODE_API_KEY" }) + ).toBe("svc"); + await expect( + viaService("opencode", { apiKeyEnv: "NOT_SET" }) + ).rejects.toMatchObject({ code: "MISSING_CREDENTIAL" }); + const viaLaunch = planeApiKeyResolver( + { + get: (name: string) => + name === "launchEnvironment" + ? { + get: (ref: string) => + ref === "K" ? { value: "launch" } : undefined, + } + : undefined, + }, + host, + { readOpencodeStore: false } + ); + expect(await viaLaunch("opencode", { apiKeyEnv: "K" })).toBe("launch"); + // Without dsh-llm's helpers the key is trimmed and the miss is a plain coded Error. + const bare = planeApiKeyResolver( + { + get: (name: string) => + name === "credentials" + ? { resolve: async () => ({ value: " v " }) } + : undefined, + }, + { + ...host, + libraries: { + ...host.libraries, + assertUsableApiKey: undefined, + LlmError: undefined, + }, + }, + { readOpencodeStore: false } + ); + expect(await bare("opencode", { apiKeyEnv: "K" })).toBe("v"); + const odd = planeApiKeyResolver( + { + get: (name: string) => + name === "credentials" ? { resolve: async () => 5 } : undefined, + }, + { ...host, libraries: { ...host.libraries, LlmError: undefined } }, + { readOpencodeStore: false } + ); + await expect(odd("opencode", { apiKeyEnv: "K" })).rejects.toMatchObject({ + code: "MISSING_CREDENTIAL", + }); + }); +}); + +describe("plane-provider · planeApiKeyResolver's two refinements of D20", () => { + const stores: FixtureStore[] = []; + + afterEach(() => { + for (const store of stores.splice(0)) { + store.cleanup(); + } + delete process.env.XDG_DATA_HOME; + }); + + it("with a credentials service mounted, a ref it misses is MISSING_CREDENTIAL even when the launch environment holds it (pi-ai's rule)", async () => { + const host = await modules(); + const service = fakeCredentials(); + const ctx = { + get: (name: string) => { + if (name === "credentials") { + return service.service; + } + return name === "launchEnvironment" + ? { + get: (ref: string) => + ref === "OPENCODE_API_KEY" ? { value: "launch" } : undefined, + } + : undefined; + }, + }; + const resolve = planeApiKeyResolver(ctx, host, { + readOpencodeStore: false, + }); + await expect( + resolve("opencode", { apiKeyEnv: "OPENCODE_API_KEY" }) + ).rejects.toMatchObject({ code: "MISSING_CREDENTIAL" }); + // The same launch environment answers once no service is mounted. + const without = planeApiKeyResolver( + { + get: (name: string) => + name === "credentials" ? undefined : ctx.get(name), + }, + host, + { readOpencodeStore: false } + ); + await expect( + without("opencode", { apiKeyEnv: "OPENCODE_API_KEY" }) + ).resolves.toBe("launch"); + }); + + it("with no ref named, OpenCode's own store answers only when the deployment opted in, and only for its own plane", async () => { + const host = await modules(); + const store = createOpencodeStore([ + { integrationId: "opencode", value: "oc_sk_store_zen" }, + ]); + stores.push(store); + process.env.XDG_DATA_HOME = store.root; + // A launch environment that answers nothing: pi's own lookup reads + // OPENCODE_API_KEY through it, and a bare context would fall back to + // `process.env`, so a contributor who exports the key would see pi's + // lookup answer and the resolver step aside. The case measures the + // keyless deployment whatever the shell holds. + const keyless = { + get: (name: string) => + name === "launchEnvironment" ? { get: () => {} } : undefined, + }; + const optedIn = planeApiKeyResolver(keyless, host, { + readOpencodeStore: true, + }); + expect(await optedIn("opencode", {})).toBe("oc_sk_store_zen"); + expect(await optedIn("opencode-go", {})).toBeUndefined(); + // A named ref never falls back to the store. + await expect( + optedIn("opencode", { apiKeyEnv: "UNSET_REF" }) + ).rejects.toMatchObject({ code: "MISSING_CREDENTIAL" }); + const off = planeApiKeyResolver(keyless, host, { + readOpencodeStore: false, + }); + expect(await off("opencode", {})).toBeUndefined(); + // The store is the LAST resort: an OPENCODE_API_KEY the launch + // environment exports is pi's own lookup, so the resolver steps aside + // (an explicit key would win over it). + const exported = planeApiKeyResolver( + { + get: (name: string) => + name === "launchEnvironment" + ? { + get: (ref: string) => + ref === "OPENCODE_API_KEY" + ? { value: "exported" } + : undefined, + } + : undefined, + }, + host, + { readOpencodeStore: true } + ); + expect(await exported("opencode", {})).toBeUndefined(); + }); +}); + +describe("plane-provider · buildPlaneProvider", () => { + it("builds each plane with pi's own OpenCode auth and all four protocols", async () => { + const { libraries } = await modules(); + const zen = buildPlaneProvider(libraries)({ + id: "opencode", + models: [], + name: "Zen", + }) as { + auth: unknown; + id: string; + name: string; + }; + + const go = buildPlaneProvider(libraries)({ + id: "opencode-go", + models: [], + name: "Go", + }) as { + auth: unknown; + }; + expect(zen).toMatchObject({ id: "opencode", name: "Zen" }); + expect(zen.auth).toBe(libraries.opencodeAuth); + expect(go.auth).toBe(libraries.opencodeGoAuth); + // What `createProvider` is handed: all four protocols on either plane + // (Google on Go too, which pi's own Go provider lacks), each one pi's + // own factory wrapped with pi's OpenCode session header. + const inputs: Record[] = []; + const wrapped = new Set(); + const spied = { + ...libraries, + createProvider: (input: Record) => { + inputs.push(input); + return libraries.createProvider(input); + }, + withOpenCodeSessionHeader: (api: unknown) => { + const header = libraries.withOpenCodeSessionHeader(api); + wrapped.add(header); + return header; + }, + }; + for (const id of ["opencode", "opencode-go"] as const) { + buildPlaneProvider(spied)({ id, models: [], name: id }); + } + expect(inputs).toHaveLength(2); + for (const input of inputs) { + const api = input.api as Record; + expect(Object.keys(api).toSorted()).toEqual([ + "anthropic-messages", + "google-generative-ai", + "openai-completions", + "openai-responses", + ]); + for (const factory of Object.values(api)) { + expect(wrapped.has(factory)).toBe(true); + } + } + }); + + it("wraps Google, and only Google, for the catalog's level ids, inside pi's session header (D16)", async () => { + const { libraries } = await modules(); + const made = new Map>(); + const handed: unknown[] = []; + const spied: HostLibraries = { + ...libraries, + apis: Object.fromEntries( + Object.entries(libraries.apis).map(([protocol, factory]) => [ + protocol, + () => { + const api = factory() as Record; + made.set(protocol, api); + return api; + }, + ]) + ) as HostLibraries["apis"], + withOpenCodeSessionHeader: (api: unknown) => { + handed.push(api); + return libraries.withOpenCodeSessionHeader(api); + }, + }; + const build = (levelThinkingModels?: ReadonlySet) => { + made.clear(); + handed.length = 0; + buildPlaneProvider(spied)({ + id: "opencode-go", + models: [], + name: "Go", + ...(levelThinkingModels === undefined ? {} : { levelThinkingModels }), + }); + // Each factory is made and then handed to the header, one protocol at + // a time, so the two lists line up. + expect(handed).toHaveLength(made.size); + return Object.fromEntries( + [...made.keys()].map((protocol, index) => [protocol, handed[index]]) + ); + }; + // No level ids: every factory's api reaches the session header as pi made it. + for (const level of [undefined, new Set()]) { + const reached = build(level); + for (const [protocol, api] of made) { + expect(reached[protocol], protocol).toBe(api); + } + } + // A level id: Google reaches it wrapped, its other entry points pi's own; + // the other three as pi made them. + const reached = build(new Set(["gemini-4-argon"])); + for (const [protocol, api] of made) { + if (protocol === "google-generative-ai") { + expect(reached[protocol]).not.toBe(api); + const google = reached[protocol] as Record; + expect(google.stream).toBe(api.stream); + expect(google.streamSimple).not.toBe(api.streamSimple); + } else { + expect(reached[protocol], protocol).toBe(api); + } + } + // Without the host's level pieces the Google api stays pi's. + const { googleLevels: _pieces, ...withoutPieces } = spied; + made.clear(); + handed.length = 0; + buildPlaneProvider(withoutPieces)({ + id: "opencode-go", + levelThinkingModels: new Set(["gemini-4-argon"]), + models: [], + name: "Go", + }); + expect(handed).toContain(made.get("google-generative-ai")); + }); +}); diff --git a/test/protocols.test.ts b/test/protocols.test.ts new file mode 100644 index 00000000..10ca1d1e --- /dev/null +++ b/test/protocols.test.ts @@ -0,0 +1,173 @@ +/** + * The protocol tables the plane routes dispatch on (`src/utils/protocols.ts`). + * + * The tripwire: every SDK the bundled catalogs name maps to a protocol, so a + * catalog that grows a new SDK fails here by name instead of shipping a model + * nothing serves; and the protocol comes from the catalog's SDK, never from a + * model's name (D16). + * + * @module test/protocols.test + */ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { piKnows } from "./support/host.ts"; +import { + allOfferedCompatFields, + COMPAT_GATES, + compatProtocols, + FAMILY_SDK_KEY, + GO_GOOGLE_BASE_URL, + isGatedCompatField, + isPlaneRoute, + isProtocol, + isServableSdk, + LEGACY_BASE, + LEGACY_PLANE_API, + MODERN_FALLBACK, + OFFERED_COMPAT, + OPENCODE_FAMILY_API, + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, + PLANE_ROUTES, + PROTOCOL_FOR_SDK, + protocolForSdk, + PROTOCOLS, +} from "./support/src.ts"; + +describe("protocols · the SDK table", () => { + it("maps every catalog SDK, mistral as completions as pi's own data does, and nothing is unserved", () => { + expect(PROTOCOL_FOR_SDK).toEqual({ + "@ai-sdk/anthropic": "anthropic-messages", + "@ai-sdk/google": "google-generative-ai", + "@ai-sdk/mistral": "openai-completions", + "@ai-sdk/openai": "openai-responses", + }); + const sdks = new Set( + [...OPENCODE_ZEN_CATALOG, ...OPENCODE_GO_CATALOG].map( + (spec) => spec.provider_npm + ) + ); + // The catalog must actually name SDKs, or the loop below proves nothing. + expect(sdks.size).toBeGreaterThan(2); + for (const sdk of sdks) { + expect(isServableSdk(sdk), `catalog SDK ${String(sdk)}`).toBe(true); + } + expect(piKnows("opencode", "mistral-large-4")).toBe("openai-completions"); + expect(protocolForSdk()).toBe("openai-completions"); + expect(protocolForSdk("@ai-sdk/cohere")).toBeUndefined(); + expect(protocolForSdk(5)).toBeUndefined(); + expect(protocolForSdk("@ai-sdk/mistral")).toBe("openai-completions"); + expect(isServableSdk("@ai-sdk/cohere")).toBe(false); + expect(isServableSdk(5)).toBe(false); + // An inherited key is not an SDK. + expect(isServableSdk("toString")).toBe(false); + expect(protocolForSdk("toString")).toBeUndefined(); + // Nothing is withheld by decision any more: no UNSERVED_* table exists. + const source = readFileSync( + new URL("../src/utils/protocols.ts", import.meta.url), + "utf8" + ); + expect(source).toContain("export const PROTOCOL_FOR_SDK"); + expect(source).not.toMatch(/export const UNSERVED/u); + }); + + it("names the four protocols and the two planes, and recognizes only those", () => { + expect(PROTOCOLS).toEqual([ + "openai-completions", + "openai-responses", + "anthropic-messages", + "google-generative-ai", + ]); + expect(PROTOCOLS.every(isProtocol)).toBe(true); + expect(isProtocol("mistral-conversations")).toBe(false); + expect(isProtocol(null)).toBe(false); + expect(PLANE_ROUTES).toEqual(["opencode", "opencode-go"]); + expect(PLANE_ROUTES.every(isPlaneRoute)).toBe(true); + expect(isPlaneRoute("opencode-responses")).toBe(false); + expect(isPlaneRoute(1)).toBe(false); + }); +}); + +describe("protocols · the base tables", () => { + it("pins the legacy family per plane, with Go's Google on the family every plane shares", () => { + expect(LEGACY_BASE.opencode).toEqual({ + "anthropic-messages": "https://opencode.ai/zen", + "google-generative-ai": "https://opencode.ai/zen/v1", + "openai-completions": "https://opencode.ai/zen/v1", + "openai-responses": "https://opencode.ai/zen/v1", + }); + expect(LEGACY_BASE["opencode-go"]).toEqual({ + "anthropic-messages": "https://opencode.ai/zen/go", + "google-generative-ai": "https://opencode.ai/inference/go/google/v1beta", + "openai-completions": "https://opencode.ai/zen/go/v1", + "openai-responses": "https://opencode.ai/zen/go/v1", + }); + expect(GO_GOOGLE_BASE_URL).toBe(OPENCODE_FAMILY_API.go["@ai-sdk/google"]); + expect(LEGACY_PLANE_API).toEqual({ + opencode: LEGACY_BASE.opencode["openai-completions"], + "opencode-go": LEGACY_BASE["opencode-go"]["openai-completions"], + }); + }); + + it("keeps the modern fallback equal to the catalog's own family map, key for key", () => { + for (const [plane, family] of [ + ["opencode", OPENCODE_FAMILY_API.zen], + ["opencode-go", OPENCODE_FAMILY_API.go], + ] as const) { + for (const protocol of PROTOCOLS) { + expect(MODERN_FALLBACK[plane][protocol], `${plane} ${protocol}`).toBe( + family[FAMILY_SDK_KEY[protocol]] + ); + } + } + }); +}); + +describe("protocols · pi-ai's compat gates", () => { + it("offers per protocol exactly the fields pi-ai's gates offer, and google none", () => { + expect([...OFFERED_COMPAT["openai-responses"]]).toEqual([ + "supportsDeveloperRole", + "supportsMaxOutputTokens", + "supportsStrictMode", + "supportsLongCacheRetention", + ]); + expect([...OFFERED_COMPAT["anthropic-messages"]]).toEqual([ + "supportsEagerToolInputStreaming", + "supportsLongCacheRetention", + "supportsCacheControlOnTools", + "supportsTemperature", + "forceAdaptiveThinking", + "allowEmptySignature", + "supportsStrictTools", + ]); + expect(OFFERED_COMPAT["openai-completions"].has("thinkingFormat")).toBe( + true + ); + expect(OFFERED_COMPAT["openai-completions"].has("openRouterRouting")).toBe( + false + ); + expect(OFFERED_COMPAT["openai-completions"].size).toBe(19); + expect(OFFERED_COMPAT["google-generative-ai"].size).toBe(0); + expect(Object.keys(COMPAT_GATES)).toContain("bedrock-converse-stream"); + }); + + it("answers which protocols take a field, the offered vocabulary, and whether a gate names it", () => { + expect(compatProtocols("supportsStrictMode")).toEqual([ + "openai-completions", + "openai-responses", + "azure-openai-responses", + "openai-codex-responses", + "bedrock-converse-stream", + ]); + expect(compatProtocols("openRouterRouting")).toEqual([]); + expect(compatProtocols("noSuchField")).toEqual([]); + const offered = allOfferedCompatFields(); + expect(offered[0]).toBe("supportsStore"); + expect(new Set(offered).size).toBe(offered.length); + expect(offered).toContain("supportsStrictTools"); + expect(isGatedCompatField("openRouterRouting")).toBe(true); + expect(isGatedCompatField("noSuchField")).toBe(false); + }); +}); diff --git a/test/public-api.test.ts b/test/public-api.test.ts index b246f70a..ec382e5c 100644 --- a/test/public-api.test.ts +++ b/test/public-api.test.ts @@ -29,13 +29,12 @@ const VALUE_EXPORTS: readonly string[] = ["Config", "apply", "inject", "name"]; /** * The type-only names: erased at runtime, still part of the contract. - * `ModelRouteConfig` is here because `PluginConfig.modelRoutes` names it. + * `ModelOverrideConfig` and `ModelRouteConfig` left with the settings that + * named them (D19). */ const TYPE_EXPORTS: readonly string[] = [ "EndpointFamily", "KeySourcePolicy", - "ModelOverrideConfig", - "ModelRouteConfig", "PluginConfig", "ResolvedPluginConfig", ]; @@ -118,7 +117,7 @@ describe("public API · src/index.ts", () => { ); expect(typeof barrel.apply).toBe("function"); expect(typeof barrel.Config).toBe("function"); - expect(barrel.inject).toEqual(["llm"]); + expect(barrel.inject).toEqual([]); expect(barrel.name).toBe((pkg as { name: string }).name); }); }); diff --git a/test/readme-parity.test.ts b/test/readme-parity.test.ts new file mode 100644 index 00000000..947db2a6 --- /dev/null +++ b/test/readme-parity.test.ts @@ -0,0 +1,117 @@ +/** + * The two READMEs are one document in two languages, and the credential copy + * says what the meter does. + * + * P-R1 corrected the English README twice (the Go plane's key, D20; the D1 + * `none` Off) and the Chinese one not at all, so a Chinese reader was still + * told that a Zen key never reaches the Go quota endpoint, the claim the + * tier-A meter case in `test/host-planes.test.ts` shows to be false for a Go + * entry that names no reference. `docs/engineering-notes.md` records an + * earlier drift of the same kind. This pins the parts that drifted: the + * number of "Changed in 1.x" bullets, the retracted isolation claim, the Go + * `apiKeyEnv` instruction, the install snippet's version range (which had + * drifted to two different 0.x ranges, neither reaching a 1.x release), and + * the `keySource` hint's order, which is + * `KEY_SOURCE_ORDER.auto` in `src/go-discovery.ts` (literal, captured, + * configured). + * + * @module test/readme-parity.test + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { en as enCopy, zh as zhCopy } from "./support/src.ts"; + +const ROOT = join(import.meta.dirname, ".."); +const read = (file: string): string => readFileSync(join(ROOT, file), "utf8"); + +const READMES = [ + ["README.md", "## Changed in 1.x"], + ["README.zh-CN.md", "## 1.x 变更"], +] as const; + +/** The top-level bullets under `heading`, up to the next `## ` heading. */ +const bulletsUnder = (text: string, heading: string): string[] => { + const lines = text.split("\n"); + const start = lines.indexOf(heading); + if (start === -1) throw new Error(`no ${heading}`); + const out: string[] = []; + for (const line of lines.slice(start + 1)) { + if (line.startsWith("## ")) break; + if (line.startsWith("- ")) out.push(line); + } + return out; +}; + +describe("README parity", () => { + it("both READMEs announce the same number of 1.x changes", () => { + const [enCount, zhCount] = READMES.map( + ([file, heading]) => bulletsUnder(read(file), heading).length + ); + // Non-vacuous: the section really is read. + expect(enCount).toBeGreaterThan(5); + expect(zhCount).toBe(enCount); + }); + + it("neither README claims a Zen key never reaches the Go quota endpoint", () => { + const en = read("README.md"); + const zh = read("README.zh-CN.md"); + expect(en).not.toMatch(/strict credential isolation/iu); + expect(zh).not.toContain("严格凭据隔离"); + // Both tell the reader to name the Go key on the Go entry: in step 3 and + // in the breaking change. + for (const [file, heading] of READMES) { + const text = read(file); + expect( + text + .split("\n") + .filter((l) => l.includes("apiKeyEnv: OPENCODE_GO_API_KEY")).length, + file + ).toBeGreaterThanOrEqual(3); + expect( + bulletsUnder(text, heading).some( + (b) => b.includes("OPENCODE_API_KEY") && b.includes("apiKeyEnv") + ), + file + ).toBe(true); + } + }); + + it("both install snippets pin one range, on package.json's major", () => { + const ranges = READMES.map(([file]) => { + const found = [ + ...read(file).matchAll(/"dsh-opencode-patch": "\^\d+\.\d+\.\d+"/gu), + ]; + // Non-vacuous: each README carries exactly one snippet range. + expect(found, file).toHaveLength(1); + return found[0]?.[0]; + }); + expect(ranges[1]).toBe(ranges[0]); + const pkg = JSON.parse(read("package.json")) as { version: string }; + const [major] = pkg.version.split("."); + expect(ranges[0]).toContain(`"^${major}.`); + }); + + it("the keySource hint names auto's order: literal, captured, then declared", () => { + const order = (hint: string, marks: readonly string[]): number[] => + marks.map((m) => { + const at = hint.indexOf(m); + expect(at, m).toBeGreaterThanOrEqual(0); + return at; + }); + const enAt = order(enCopy.keySourceHint, [ + "literal", + "captured", + "declared reference", + ]); + expect(enAt.toSorted((a, b) => a - b)).toEqual(enAt); + const zhAt = order(zhCopy.keySourceHint, [ + "字面密钥", + "捕获的密钥", + "声明的引用", + ]); + expect(zhAt.toSorted((a, b) => a - b)).toEqual(zhAt); + }); +}); diff --git a/test/reload.test.ts b/test/reload.test.ts index e2172373..bd9da911 100644 --- a/test/reload.test.ts +++ b/test/reload.test.ts @@ -10,10 +10,14 @@ * wrapped the leftover wrapper. So the sequence a patch has to survive is: * apply A → apply B → dispose A → B intact → dispose B → the originals restored * EXACTLY, and the same with the disposals reversed. - * Two harder shapes follow: a reload whose config (for the listing, its keyless - * option) CHANGED, where the live answer must be B's alone, which a site that - * stacked B on A would get wrong; and a foreign wrapper set on top while the - * newest install is disposed first. + * Two harder shapes follow: a reload whose config (for the Settings graft, its + * committed entries) CHANGED, where the live answer must be B's alone, which a + * site that stacked B on A would get wrong; and a foreign wrapper set on top + * while the newest install is disposed first. + * + * Two sites are left after P-R1: model discovery, and the `settings.describe` + * graft. The listing, model-info and plane-adapter wrappers went with the + * hidden routes — the planes are the plugin's own registration, not a patch. * "Exactly" is two facts: an own method comes back as the same function with * the same attributes, and an inherited one leaves no own property behind, so * the prototype answers again. @@ -33,24 +37,23 @@ import { adaptersOf, type HostLlm, mountTierA, - planeKeysOnly, + PLANES, + piAiRestarts, s0Providers, type TierA, + waitForPlaneOwner, } from "./support/host.ts"; import { apply, - baseForProtocol, + Config, type CordisContext, decorateModelDiscovery, - decorateModelInfo, - decoratePlaneAdapters, - findModelSpecOn, - hideResponsesRoute, + inject, inspectPatch, + installDescribeGraft, mergeDiscoveredModels, - protocolFor, + name, type ResolvedPluginConfig, - RESPONSES_ROUTE, resolveConfig, } from "./support/src.ts"; @@ -64,9 +67,6 @@ type Shape = "inherited" | "own"; type Method = (...args: never[]) => unknown; -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - /** * The methods as own data properties of an object literal, or as methods on the * prototype of a fresh object, which is where a class keeps them. @@ -111,38 +111,9 @@ const ownValues = ( ]) ); -const contextWindowOf = (value: unknown): unknown => - isRecord(value) && isRecord(value.context) - ? value.context.contextWindow - : undefined; - -const nameOf = (value: unknown): unknown => - isRecord(value) ? value.name : undefined; - -const baseUrlOf = (value: unknown): unknown => - isRecord(value) ? value.baseUrl : undefined; - -const idsOf = (value: unknown): unknown => - Array.isArray(value) - ? value.map((entry: unknown) => (isRecord(entry) ? entry.id : entry)) - : value; - /** The settings namespace the tier-A discovery probe registers under. */ const PROBE_NAMESPACE = "reload-probe"; -/** The base a plane's catalog dispatches `id` to, under the default endpoint. */ -const planeBase = ( - route: "opencode" | "opencode-go", - id: string -): string | undefined => { - const spec = findModelSpecOn(route === "opencode" ? "zen" : "go", id); - const protocol = spec === undefined ? undefined : protocolFor(spec); - if (protocol === undefined) { - throw new TypeError(`no protocol for ${id} on ${route}`); - } - return baseForProtocol(route, protocol, resolveConfig().endpoint); -}; - const asContext = (llm: object): CordisContext => ({ llm }) as unknown as CordisContext; @@ -187,11 +158,11 @@ interface Site { * The same site on the real host (tier A), where the stand-in's probe does not * apply: a call whose answer through the site's decoration differs from the * host's own THERE, so the block's "survivor still decorates" and "last - * dispose restores" checks can fail. Without one, the listing would be probed - * by a filter with nothing to filter (tier A mounts no hidden route) and - * discovery not at all. + * dispose restores" checks can fail. Without one, discovery would not be + * probed there at all. The `settings.describe` graft has none: tier A has no + * settings service, so its stand-in cases above are its cases. */ - readonly tierA: { + readonly tierA?: { /** The install the block drives, where the site's default decorates nothing there. */ readonly install?: (llm: object) => (() => void) | undefined; /** Host state the probe needs, set before the first install. */ @@ -200,70 +171,70 @@ interface Site { }; } -const LISTED = [ - { id: "opencode", name: "opencode" }, - { id: RESPONSES_ROUTE, name: RESPONSES_ROUTE }, -]; - -/** A priced Zen id and a free one, for the keyless filter. */ -const PRICED_AND_FREE = ["gpt-5.4", "beam-free"] as const; +/** 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); + }); + } +}; -const listingMethods = (): Record => ({ - listConfigurableProviders: () => [ - { provider: "opencode" }, - { provider: RESPONSES_ROUTE }, +/** pi-ai's descriptor as `settings.describe()` builds it without the planes. */ +const describeMethods = (): Record => ({ + describe: () => [ + { ns: "llm-pi-ai", revision: 3, value: { providers: { deepseek: {} } } }, + { ns: "other", value: {} }, ], - listModels: async () => PRICED_AND_FREE.map((id) => ({ id })), - listProviders: () => LISTED.map((entry) => ({ ...entry })), }); -/** The one adapter both plane routes share, as pi-ai registers it. */ -const adapterMethods = (): Record => ({ - modelOf: () => ({ id: "x", reasoning: false }), - prepareCall: async () => ({}), - resolveModel: async () => ({}), -}); +/** The committed plane entries the default activation grafts, and A's. */ +const ENTRIES_B = { opencode: { apiKeyEnv: "OPENCODE_API_KEY" } }; +const ENTRIES_A = { opencode: { apiKeyEnv: "ACTIVATION_A_KEY" } }; -const adapterOf = (llm: Record): object => { - const { adapters } = llm; - const row: unknown = - adapters instanceof Map ? adapters.get("opencode") : undefined; - if (!isRecord(row) || !isRecord(row.adapter)) { - throw new TypeError("no adapter behind the opencode route"); - } - return row.adapter; -}; +/** + * The graft installed the way `apply` installs it: through a `settings` + * scope whose effects are collected and disposed together. + */ +const installGraft = + (entries: Record) => + (settings: object): (() => void) => { + const cleanups: (() => void)[] = []; + installDescribeGraft( + { + inject: (_deps: string[], callback: (scope: unknown) => void) => { + callback({ + effect: (factory: () => () => void) => { + cleanups.push(factory()); + }, + settings, + }); + }, + } as unknown as CordisContext, + { + committedEntries: () => entries, + piAiNamespace: () => "llm-pi-ai", + } + ); + return () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + cleanup(); + } + }; + }; const SITES: readonly Site[] = [ - { - decorated: [{ id: "opencode", name: "opencode" }], - host: (shape) => shaped(shape, listingMethods()), - install: (llm) => hideResponsesRoute(asContext(llm), resolveConfig()), - keys: ["listProviders", "listConfigurableProviders", "listModels"], - name: "listing", - probe: (llm) => call(llm, "listProviders"), - raw: LISTED, - // The keyless filter is per activation: under A (no credential) a priced - // id is dropped, under the default (nobody asked) the listing is kept, so - // a stacked default install would keep A's narrowing. - reconfigured: { - installA: (llm) => - hideResponsesRoute(asContext(llm), resolveConfig(), { - hasCredential: () => false, - }), - probe: (llm) => call(llm, "listModels", "opencode"), - }, - // pi-ai lists the plane's catalog once a key is stored; with no credential - // the keyless filter drops the priced ids the host's own answer keeps. - tierA: { - install: (llm) => - hideResponsesRoute(asContext(llm), resolveConfig(), { - hasCredential: () => false, - }), - probe: async (llm) => idsOf(await llm.listModels("opencode")), - }, - targetOf: (llm) => llm, - }, { decorated: mergeDiscoveredModels([{ id: "minimax-m3" }], "go"), host: (shape) => @@ -302,106 +273,27 @@ const SITES: readonly Site[] = [ targetOf: (llm) => llm, }, { - decorated: findModelSpecOn("go", "minimax-m3")?.context_window, - host: (shape) => shaped(shape, { resolveModelInfo: async () => ({}) }), - install: (llm, config = resolveConfig()) => - decorateModelInfo(asContext(llm), config), - keys: ["resolveModelInfo"], - name: "model-info", - probe: async (llm) => - contextWindowOf( - await call(llm, "resolveModelInfo", "opencode-go", "minimax-m3") - ), - raw: undefined, - // A name override is applied only where one is declared, so a stacked - // default install would keep the first activation's name. - reconfigured: { - config: resolveConfig({ - modelOverrides: { "minimax-m3": { name: "Activation A" } }, - }), - probe: async (llm) => - nameOf( - await call(llm, "resolveModelInfo", "opencode-go", "minimax-m3") - ), - }, - // pi's installed catalog is an older snapshot than ours for this id, and the - // decoration's window is authoritative. - tierA: { - probe: async (llm) => - contextWindowOf(await llm.resolveModelInfo("opencode", "gpt-5.4")), - }, - targetOf: (llm) => llm, - }, - { - decorated: planeBase("opencode", "deepseek-v4-pro"), - host: (shape) => { - const adapter = shaped(shape, adapterMethods()); - return { - adapters: new Map([ - ["opencode", { adapter }], - ["opencode-go", { adapter }], - ]), - }; - }, - install: (llm, config = resolveConfig()) => - decoratePlaneAdapters(llm as never, config), - keys: ["resolveModel", "prepareCall", "modelOf"], - name: "plane-adapters", - probe: async (llm) => - baseUrlOf( - await call( - adapterOf(llm), - "resolveModel", - "opencode", - "deepseek-v4-pro" - ) - ), - raw: undefined, - // The fill keeps a window the adapter already answered, so a stacked - // default install would keep the first activation's clamp; and `modelOf` - // (its own patch) fills only the levels the adapter refuses, so a stacked - // default install would build on A's declared levels instead of the - // catalog's. - reconfigured: { - config: resolveConfig({ - maxContextWindow: 1000, - modelOverrides: { "deepseek-v4-pro": { reasoningEfforts: ["low"] } }, - }), - probe: async (llm) => { - const adapter = adapterOf(llm); - const resolved = await call( - adapter, - "resolveModel", - "opencode", - "deepseek-v4-pro" - ); - const model = await call( - adapter, - "modelOf", - {}, - "opencode", - "deepseek-v4-pro" - ); - return { - contextWindow: contextWindowOf(resolved), - thinkingLevelMap: isRecord(model) - ? model.thinkingLevelMap - : undefined, - }; + decorated: [ + { + ns: "llm-pi-ai", + revision: 3, + value: { providers: { deepseek: {}, ...ENTRIES_B } }, }, + { ns: "other", value: {} }, + ], + host: (shape) => shaped(shape, describeMethods()), + install: installGraft(ENTRIES_B), + keys: ["describe"], + name: "settings-describe", + probe: (settings) => call(settings, "describe"), + raw: describeMethods().describe?.(), + // The entries are per activation: A grafts its own, so a stacked default + // install would keep showing A's. + reconfigured: { + installA: installGraft(ENTRIES_A), + probe: (settings) => call(settings, "describe"), }, - tierA: { - probe: async (llm) => - baseUrlOf( - await call( - adaptersOf(llm).get("opencode")?.adapter ?? {}, - "resolveModel", - "opencode", - "deepseek-v4-pro" - ) - ), - }, - targetOf: adapterOf, + targetOf: (settings) => settings, }, ]; @@ -601,32 +493,39 @@ describe("reload · on the real host (tier A)", { timeout: 15_000 }, () => { tier = undefined; }); + // Tier A has no settings service, so the graft's own host stand-ins above + // are its cases; every site with a tier-A probe (discovery) runs on the real + // `llm` service here. it.each( - SITES.flatMap((site) => [ - [site.name, "first disposed first", site, true] as const, - [site.name, "last disposed first", site, false] as const, - ]) - )("%s · %s", async (name, _order, site, fifo) => { - // Plane keys stored, so pi-ai lists the planes' catalogs (the listing probe). - tier = await mountTierA({ - credentials: planeKeysOnly(), - providers: s0Providers(), - }); + SITES.flatMap((site) => + site.tierA === undefined + ? [] + : [ + [ + site.name, + "first disposed first", + site, + site.tierA, + true, + ] as const, + [ + site.name, + "last disposed first", + site, + site.tierA, + false, + ] as const, + ] + ) + )("%s · %s", async (_siteName, _order, site, tierA, fifo) => { + tier = await mountTierA({ providers: s0Providers() }); const { llm } = tier; - let target: object = llm; - if (name === "plane-adapters") { - const shared = adaptersOf(llm).get("opencode")?.adapter; - expect(shared).toBeDefined(); - // pi-ai serves both planes from one instance, which is the nesting the - // plane-adapter patch composes over. - expect(adaptersOf(llm).get("opencode-go")?.adapter).toBe(shared); - target = shared ?? {}; - } - site.tierA.setup?.(llm); - const install = site.tierA.install ?? site.install; + const target: object = llm; + tierA.setup?.(llm); + const install = tierA.install ?? site.install; const listed = llm.listProviders(); const info = await llm.resolveModelInfo("opencode", "deepseek-v4-pro"); - const raw = await site.tierA.probe(llm); + const raw = await tierA.probe(llm); for (const key of site.keys) { expect(Object.hasOwn(target, key), key).toBe(false); } @@ -642,7 +541,7 @@ describe("reload · on the real host (tier A)", { timeout: 15_000 }, () => { } // The probe has to tell the decoration from the host's own answer here, // or the two checks against `raw` below could not fail. - const decorated = await site.tierA.probe(llm); + const decorated = await tierA.probe(llm); expect(decorated).not.toEqual(raw); (fifo ? a : b)?.(); @@ -653,18 +552,15 @@ describe("reload · on the real host (tier A)", { timeout: 15_000 }, () => { `survivor intact: ${key}` ).toBe(survivor.get(key)); } - await expect(site.tierA.probe(llm), "survivor decorates").resolves.toEqual( + await expect(tierA.probe(llm), "survivor decorates").resolves.toEqual( decorated ); - if (name === "plane-adapters") { - expect(decorated).toBe(planeBase("opencode", "deepseek-v4-pro")); - } (fifo ? b : a)?.(); for (const key of site.keys) { expect(Object.hasOwn(target, key), `restored: ${key}`).toBe(false); } - await expect(site.tierA.probe(llm), "restored").resolves.toEqual(raw); + await expect(tierA.probe(llm), "restored").resolves.toEqual(raw); // Smoke: other calls through the restored receiver still answer as before. expect(llm.listProviders()).toEqual(listed); await expect( @@ -673,86 +569,86 @@ describe("reload · on the real host (tier A)", { timeout: 15_000 }, () => { }); }); -describe("reload · through apply()", () => { +describe("reload · through apply()", { timeout: 15_000 }, () => { + let tier: TierA | undefined; + + afterEach(async () => { + await tier?.dispose(); + tier = undefined; + }); + it("apply() twice, the first disposed first: every host method the plugin patched is restored exactly", async () => { // The global fetch is patched too, and stacks the same way; it is not a host // service method and is not asserted here (P6 owns it). vi.stubGlobal("fetch", vi.fn()); vi.spyOn(console, "info").mockImplementation(() => { - // Silenced: the activation line and the hidden-route stand-down. + // Silenced: the activation line. }); - class Adapter { - modelOf(): unknown { - return { id: "x", reasoning: false }; - } - async prepareCall(): Promise { - return {}; - } - async resolveModel(): Promise { - return {}; - } - } - const adapter = new Adapter(); class HostLlm { - readonly adapters = new Map([ - ["opencode", { adapter }], - ["opencode-go", { adapter }], - ]); async discoverModels(): Promise { return [{ id: "minimax-m3" }]; } - listConfigurableProviders(): unknown { - return [{ provider: "opencode" }, { provider: RESPONSES_ROUTE }]; - } - async listModels(): Promise { - return [{ id: "opencode-model" }]; - } listProviders(): unknown { return LISTED.map((entry) => ({ ...entry })); } - async resolveModelInfo(): Promise { - return {}; + } + class HostSettings { + describe(): unknown { + return [{ ns: "llm-pi-ai", value: { providers: {} } }]; } } const llm = new HostLlm(); - const llmKeys = [ - "listProviders", - "listConfigurableProviders", - "listModels", - "discoverModels", - "resolveModelInfo", - ]; - const adapterKeys = ["resolveModel", "prepareCall", "modelOf"]; - - const cleanups: (() => void)[] = []; - const ctxFor = (): CordisContext => - ({ - effect: (fn: () => unknown) => { - const cleanup = fn(); - if (typeof cleanup === "function") { - cleanups.push(() => { - cleanup(); - }); - } + const settings = new HostSettings(); + // The plugin patches `llm.discoverModels` and `settings.describe` now; the + // plane routes are its own registration, not a patch (P-R1). + const llmKeys = ["discoverModels"]; + + /** One activation: a stand-in whose `inject` hands over a scope with the service. */ + const activation = (): { ctx: CordisContext; dispose: () => void } => { + const cleanups: (() => void)[] = []; + const effect = (fn: () => unknown) => { + const cleanup = fn(); + if (typeof cleanup === "function") { + cleanups.push(() => { + cleanup(); + }); + } + }; + const ctx = { + effect, + fiber: { uid: 1 }, + inject: (deps: string[], callback: (scope: unknown) => void) => { + callback( + deps.includes("llm") ? { effect, llm } : { effect, settings } + ); }, - llm, on: vi.fn(), - }) as unknown as CordisContext; + } as unknown as CordisContext; + return { + ctx, + dispose: () => { + for (const cleanup of cleanups.toReversed()) { + cleanup(); + } + }, + }; + }; const ownBefore = { - adapter: new Set(Reflect.ownKeys(adapter)), llm: new Set(Reflect.ownKeys(llm)), + settings: new Set(Reflect.ownKeys(settings)), }; - apply(ctxFor()); - apply(ctxFor()); - expect(cleanups).toHaveLength(2); + const a = activation(); + const b = activation(); + void apply(a.ctx); + void apply(b.ctx); // The behavioural tripwire for "patchMethod is the only way": whatever // syntax a later site used, every function `apply()` left as an own property // of a host object carries the helper's record. A bare `llm.x = wrapped` // would leave an own function with none. for (const [label, host] of [ ["llm", llm], - ["adapter", adapter], + ["settings", settings], ] as const) { const added = Reflect.ownKeys(host).filter( (key) => !ownBefore[label].has(key) @@ -769,34 +665,70 @@ describe("reload · through apply()", () => { } } const llmB = ownValues(llm, llmKeys); - const adapterB = ownValues(adapter, adapterKeys); for (const key of llmKeys) { expect(llmB.get(key), key).toBeTypeOf("function"); } - for (const key of adapterKeys) { - expect(adapterB.get(key), key).toBeTypeOf("function"); - } + const describeB = Reflect.getOwnPropertyDescriptor( + settings, + "describe" + )?.value; + expect(describeB).toBeTypeOf("function"); - cleanups[0]?.(); + a.dispose(); for (const key of llmKeys) { expect(Reflect.getOwnPropertyDescriptor(llm, key)?.value, key).toBe( llmB.get(key) ); } - for (const key of adapterKeys) { - expect(Reflect.getOwnPropertyDescriptor(adapter, key)?.value, key).toBe( - adapterB.get(key) - ); - } - expect(llm.listProviders()).toEqual([{ id: "opencode", name: "opencode" }]); + expect(Reflect.getOwnPropertyDescriptor(settings, "describe")?.value).toBe( + describeB + ); - cleanups[1]?.(); + b.dispose(); for (const key of llmKeys) { expect(Object.hasOwn(llm, key), key).toBe(false); } - for (const key of adapterKeys) { - expect(Object.hasOwn(adapter, key), key).toBe(false); - } + expect(Object.hasOwn(settings, "describe")).toBe(false); expect(llm.listProviders()).toEqual(LISTED); }); + + it("apply() twice on the real host (tier A): the planes end owned by the second activation, and pi-ai restarts exactly once", async () => { + vi.stubGlobal("fetch", vi.fn()); + vi.spyOn(console, "info").mockImplementation(() => { + // Silenced: the activation lines. + }); + tier = await mountTierA({ plugin: {}, providers: s0Providers() }); + await waitForPlaneOwner(tier.llm); + const first = tier.pluginFiber; + const second = await tier.ctx.plugin( + { apply, Config, inject, name } as never, + {} as never + ); + const restarts = piAiRestarts(tier.ctx); + // The first activation's adapter, which serves the planes until it goes. + const firstAdapters = new Map( + PLANES.map((plane) => [plane, adaptersOf(tier!.llm).get(plane)?.adapter]) + ); + for (const plane of PLANES) { + expect(firstAdapters.get(plane)?.constructor.name).toBe("PlaneAdapter"); + } + await first?.dispose(); + await until( + () => + PLANES.every((plane) => { + const adapter = adaptersOf(tier!.llm).get(plane)?.adapter; + return ( + adapter?.constructor.name === "PlaneAdapter" && + adapter !== firstAdapters.get(plane) + ); + }), + "the second activation's adapter serves both planes" + ); + // The first's teardown restarts pi-ai once so it re-resolves; the second + // activation's listener keeps it off the planes, so they go to the second. + expect(restarts.count()).toBe(1); + await second.dispose(); + await waitForPlaneOwner(tier.llm, PLANES, "PiAiAdapter"); + restarts.dispose(); + }); }); diff --git a/test/request-shape.test.ts b/test/request-shape.test.ts index efd06343..78ba3f70 100644 --- a/test/request-shape.test.ts +++ b/test/request-shape.test.ts @@ -11,7 +11,6 @@ import { describe, expect, it } from "vitest"; import { ANTHROPIC_VERSION, - INTERNAL_ROUTES, authHeaderFor, catalogPlaneForRoute, gatewayPlaneFor, @@ -25,10 +24,11 @@ import { describe("request-shape · isZenProvider", () => { it("request-shape · isZenProvider: the Zen Google route is Zen, the Go routes are Go", () => { - // `opencode-google` is the Zen plane's Google route. It starts with - // `opencode-go`, so a predicate that looked for `go` anywhere read it as the - // subscription plan; the plane is `opencode-go` or one of its own - // `opencode-go-*` routes, and nothing else. + // `opencode-google` was the Zen plane's Google route before P-R1, and a + // relay row may still carry the name. It starts with `opencode-go`, so a + // predicate that looked for `go` anywhere read it as the subscription + // plan; the plane is `opencode-go` or an `opencode-go-*` route, and + // nothing else. expect(isZenProvider("opencode-google")).toBe(true); expect(isZenProvider("opencode-go-google")).toBe(false); expect(isZenProvider("opencode-go-responses")).toBe(false); @@ -45,10 +45,19 @@ describe("request-shape · isZenProvider", () => { it("agrees with catalogPlaneForRoute on every route the plugin serves", () => { // The defect was these two predicates disagreeing; a route added to or - // renamed in responses-routes.ts must not let them drift apart again. The + // renamed in either reader must not let them drift apart again. The // set is bounded on purpose: outside OpenCode routes the two differ by // design (isZenProvider wants an `opencode` name; the plane defaults). - const routes = ["opencode", "opencode-go", ...INTERNAL_ROUTES]; + // Literal ids: the planes, and route ids a relay or an older row may still + // carry, which must classify the same way under both readers. + const routes = [ + "opencode", + "opencode-go", + "opencode-responses", + "opencode-go-responses", + "opencode-relay", + "opencode-go-relay", + ]; expect(routes.length).toBeGreaterThan(2); for (const route of routes) { expect(isZenProvider(route), route).toBe( diff --git a/test/responses-provider.test.ts b/test/responses-provider.test.ts deleted file mode 100644 index db7b8fcf..00000000 --- a/test/responses-provider.test.ts +++ /dev/null @@ -1,1907 +0,0 @@ -import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; -/** - * `responses-provider.ts` — the plugin registering the gateway's non-default - * planes itself, so the user keeps their existing provider and key. - * - * The mount is exercised against a **faithful stand-in for the host**, not a - * spy. A spy can only prove that this plugin called something; it cannot prove - * the mount survives the composition it is actually mounted into. So the stand-in - * below reproduces the four host behaviours the mount has to survive, each of - * which threw at mount time before this was fixed and none of which pointed at - * its cause: - * - * | host behaviour | reproduced by | what used to happen | - * | --- | --- | --- | - * | the registry keys its runtime by `apply` identity, so the FIRST instance's schema validates the SECOND mount | `plugin()` re-running the recorded `Config` | `providers.get expected object` | - * | `apply` declares the WHOLE installed catalog, so a second instance collides on every catalog provider | `registerConfigurableProviders` rejecting a provider another registration holds | `DUPLICATE_DIRECTORY` | - * | model discovery is keyed by settings namespace, and a child plugin INHERITS its parent entry's id | `registerModelDiscovery` rejecting a namespace it already holds | `DUPLICATE_DISCOVERY` | - * | `registerPiAiFlows` registers one flow per installed catalog provider | `inject(['authorization'])` | `DUPLICATE_FLOW` | - - * Each case below asserts the composition SURVIVES, and one case asserts the - * shape that used to be passed is the shape the stand-in refuses — so a - * regression reintroduces a failure the test can name. - */ -import { describe, expect, it, vi } from "vitest"; - -import { - type CordisContext, - GOOGLE_INTERNAL_ROUTE, - OPENCODE_PLANE_API, - RESPONSES_SDK, - ROUTE_FOR_PLANE_PROTOCOL, - SELF_MOUNTED_ROUTES, - baseForProtocol, - declaredProviderModels, - getLiveGoCatalog, - getLiveZenCatalog, - inheritedCredentialRef, - internalRouteFor, - loadPiAi, - loadPiAiAdapterHost, - loadPiAiModule, - modelsForSdk, - narrowToDeclared, - reasoningEffortsFor, - registerResponsesProvider, - undescribedDeclaredModels, - unroutedDeclaredModels, -} from "./support/src.ts"; - -const MUSE = "muse-spark-1.2"; - -/** The package name the loader entry is matched on. */ -const PI_AI = "@deepseek-ai/dsh-llm-pi-ai"; - -/** - * A stand-in for the installed pi-ai catalog. - * - * `apply` declares ALL of it as configurable providers regardless of what its own - * config names, which is precisely why a second instance cannot declare its own - * directory. One entry is enough to reproduce the collision. - */ -const CATALOG = ["openai", "anthropic"] as const; - -describe("responses-provider: the model list", () => { - it("comes from the catalog, not from a hand-written list", () => { - // Every model whose provider.npm names the OpenAI SDK is served on - // /responses, so the route covers all of them. The bundled shim seeds one; - // a live refresh widens it without a code change. - const models = modelsForSdk(RESPONSES_SDK); - expect(models.length).toBeGreaterThan(0); - expect(models.map((m) => m.id)).toContain(MUSE); - for (const model of models) { - expect(model.contextWindow).toBeGreaterThan(0); - expect(model.maxTokens).toBeGreaterThan(0); - expect(model.input.length).toBeGreaterThan(0); - } - }); - - it("lists each model once, even across both planes", () => { - const ids = modelsForSdk(RESPONSES_SDK).map((m) => m.id); - expect(new Set(ids).size).toBe(ids.length); - }); - - it("serves nothing for a protocol no model names", () => { - expect(modelsForSdk("@ai-sdk/does-not-exist")).toEqual([]); - }); - - it("names the SDK it dispatches on", () => { - // The constant the whole split hangs off; a typo here would silently stop - // redirecting every Responses model. - expect(RESPONSES_SDK).toBe("@ai-sdk/openai"); - }); - - it("derives reasoningEfforts for effort, toggle, and unelaborated models", () => { - expect( - reasoningEffortsFor({ - context_window: 1000, - id: "effort-model", - input_modalities: ["text"], - max_output_tokens: 100, - name: "Effort", - reasoning: true, - reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], - }) - ).toEqual({ off: null, low: "low", high: "high", max: "max" }); - - // Toggle models like minimax-m3 on Go plan - expect( - reasoningEffortsFor({ - context_window: 1000, - id: "minimax-m3", - input_modalities: ["text"], - max_output_tokens: 100, - name: "MiniMax-M3", - reasoning: true, - reasoning_options: [{ type: "toggle" }], - }) - ).toEqual({ off: null, low: "low", medium: "medium", high: "high" }); - - // Unelaborated reasoning models like minimax-m2.7 - expect( - reasoningEffortsFor({ - context_window: 1000, - id: "minimax-m2.7", - input_modalities: ["text"], - max_output_tokens: 100, - name: "MiniMax-M2.7", - reasoning: true, - reasoning_options: [], - }) - ).toEqual({ off: null, low: "low", medium: "medium", high: "high" }); - - // Non-reasoning model - expect( - reasoningEffortsFor({ - context_window: 1000, - id: "plain-model", - input_modalities: ["text"], - max_output_tokens: 100, - name: "Plain", - }) - ).toBeUndefined(); - }); - - it("carries reasoningEfforts into ModelProfile so llm-pi-ai exposes thinking levels", () => { - const anthropicModels = modelsForSdk("@ai-sdk/anthropic"); - const m3 = anthropicModels.find((m) => m.id === "minimax-m3"); - expect(m3).toBeDefined(); - expect(m3?.reasoningEfforts).toEqual({ - off: null, - low: "low", - medium: "medium", - high: "high", - }); - }); -}); - -/** - * A minimal but faithful cordis: service isolation, context extension, `inject`, - * and a registry that keys runtime records by `apply` identity. - * - * Only what the mount actually exercises is modelled, and each piece exists - * because omitting it would make the test pass against a host the real one is - * not. Returned object also carries the observables a case asserts on. - */ -const createHost = (options: { onPlugin?: (config: unknown) => void } = {}) => { - const ISOLATE = Symbol("isolate"); - const ROOT = Symbol("root"); - /** Service instances per isolation label. */ - const realms = new Map>([ - [ROOT, new Map()], - ]); - /** Runtime records keyed by `apply` identity — the registry's own cache. */ - const runtimes = new Map< - () => unknown, - { Config?: (raw: unknown) => unknown } - >(); - const observables = { - /** Every `registerFlow` call that actually reached the service. */ - authFlows: [] as string[], - /** Providers the mounted instances declared in the directory. */ - directory: [] as string[], - discoveries: [] as string[], - adapters: [] as string[], - isolated: [] as string[], - extended: [] as string[], - /** What the plugin reported at INFO, so a mount failure names its cause. */ - infos: [] as string[], - /** Every service name `provide` registered, in order. */ - provided: [] as string[], - }; - - /** The fiber `plugin` starts, reduced to what the mount observes. */ - interface FakeFiber { - dispose: () => unknown; - } - /** The stand-in `llm-pi-ai` module, as the loader hands it over. */ - interface FakePiAi { - Config: (raw: unknown) => unknown; - apply: (ctx: HostCtx, config: unknown) => void; - inject?: unknown; - name: string; - } - /** - * A cordis context, reduced to what the mount touches. - * - * Typed rather than `Record` so the stand-in itself is - * checked: an `apply` that reads a method this shape does not have would - * otherwise be a silent `undefined` at mount time. - */ - type HostCtx = Record & { - effect: (fn: () => unknown) => unknown; - extend: (meta?: Record) => HostCtx; - fiber: { entry: { options: { id: string } } }; - get: (name: string) => unknown; - inject: (deps: string[], cb: (scope: HostCtx) => void) => void; - isolate: (name: string) => HostCtx; - logger: { - error: (...args: unknown[]) => void; - info: (msg: string, ...args: unknown[]) => void; - warn: (...args: unknown[]) => void; - }; - on: (event?: string, callback?: unknown) => void; - plugin: (plugin: unknown, config?: unknown) => FakeFiber; - provide: (name: string, value: unknown) => () => void; - }; - - const ENTRY_ID = "dsh-opencode-patch"; - - /** Resolve a service the way cordis does: nearest isolation label, else root. */ - const resolveService = ( - chain: Map, - name: string - ): unknown => { - const label = chain.get(name); - // An isolated name resolves in its OWN scope and nowhere else — that is what - // makes hiding a service possible. Falling back to the parent here would let - // the very inject this module isolates reach the host's service, which is the - // DUPLICATE_FLOW a stub that leaked could never have shown. - if (label !== undefined) { - return realms.get(label)?.get(name); - } - return realms.get(ROOT)?.get(name); - }; - - const makeCtx = ( - parent: HostCtx | null, - isolateChain: Map, - entryId: string - ): HostCtx => { - const ctx: HostCtx = Object.create(parent ?? { ctx: true }); - - // cordis: reads and writes of an isolated service resolve in a new scope. - // One fresh label per call; the NAME is what the isolation is keyed on. - ctx[ISOLATE] = isolateChain; - // Regular functions bound to `this`, not arrows over the closure: cordis's - // context methods are receiver-bound, so a shadowed child (the `llm` facade) - // must be the receiver of anything called on it. An arrow keeps building - // children off the LEXICAL context instead, which makes a real fix look like - // a no-op — the facade would never be reached. - ctx.isolate = function isolate(this: HostCtx, name: string) { - observables.isolated.push(name); - return makeCtx( - this, - new Map(isolateChain).set(name, Symbol(name)), - entryId - ); - }; - ctx.extend = function extend( - this: HostCtx, - meta: Record = {} - ) { - observables.extended.push(...Object.keys(meta)); - // `defineProperty`, not `Object.assign`: a service on the parent is an - // inherited GETTER, and shadowing it is the whole reason `extend` exists. - const child = Object.create(this); - for (const prop of Reflect.ownKeys(meta)) { - Object.defineProperty( - child, - prop, - Object.getOwnPropertyDescriptor(meta, prop) ?? {} - ); - } - return child; - }; - ctx.get = function get(this: HostCtx, name: string) { - return resolveService(this[ISOLATE] as Map, name); - }; - // cordis: a service is registered through `provide`, which stores it - // against the isolation scope the reading context resolves in; the proxy - // then answers `ctx[name]` from that store. The stand-in mirrors the half - // the rotation handoff depends on: a provided name is readable on the - // context, and the registration can be withdrawn. - ctx.provide = function provide( - this: HostCtx, - name: string, - value: unknown - ) { - const label = (this[ISOLATE] as Map).get(name) ?? ROOT; - const realm = realms.get(label) ?? new Map(); - realms.set(label, realm); - realm.set(name, value); - observables.provided.push(name); - Object.defineProperty(this, name, { - configurable: true, - get: () => resolveService(this[ISOLATE] as Map, name), - }); - return () => { - realm.delete(name); - }; - }; - // A Service is a getter defined ONCE on the root context and resolved per - // read through `this`, so a child sees its OWN isolation and a shadowed - // service (the `llm` facade) is inherited by everything below it. Defining - // one per context would break both. - if (parent === null) { - for (const name of ["llm", "authorization", "settings"] as const) { - Object.defineProperty(ctx, name, { - configurable: true, - get(this: HostCtx) { - return resolveService(this[ISOLATE] as Map, name); - }, - }); - } - } - ctx.plugin = function plugin( - this: HostCtx, - subject: unknown, - config?: unknown - ) { - options.onPlugin?.(config); - const record = subject as Partial; - // Named for what it IS — the plugin's own `apply` — rather than - // `callback`, which is what the enclosing registry helper calls its own - // parameter and what the linter rightly refused to shadow. - const applyFn = record.apply as () => unknown; - // The registry's own rule: the FIRST instance's schema is the one on - // record, and it validates whatever every later mount is handed. - let runtime = runtimes.get(applyFn); - if (runtime === undefined) { - runtime = { Config: record.Config }; - runtimes.set(applyFn, runtime); - } - const resolved = - runtime.Config === undefined ? config : runtime.Config(config); - // A child plugin INHERITS its parent entry, which is why the mounted - // instance reads the namespace off the plugin's own entry id. - const fiberCtx = makeCtx(this, new Map(isolateChain), entryId); - fiberCtx.fiber = { entry: { options: { id: entryId } } }; - fiberCtx.effect = (fn: () => unknown) => fn(); - // Faithful: the host fires `loader/volatile-update` after a config change, - // and the mounted plugin answers it by re-running its directory sync — which - // is the only path that reaches the handle our facade hands back. - const volatile: (() => void)[] = []; - fiberCtx.on = (event?: string, callback?: unknown) => { - if ( - event === "loader/volatile-update" && - typeof callback === "function" - ) { - volatile.push(callback as () => void); - } - }; - fiberCtx.logger = { error: () => {}, info: () => {}, warn: () => {} }; - fiberCtx.inject = (deps: string[], cb: (scope: HostCtx) => void) => { - // An unresolved dependency simply never fires — that is what isolating - // a service is FOR. - for (const dep of deps) { - if (fiberCtx.get(dep) === undefined) { - return; - } - } - cb(fiberCtx); - }; - (record.apply as (c: HostCtx, cfg: unknown) => void)(fiberCtx, resolved); - for (const listener of volatile) { - listener(); - } - // Awaitable via a real promise rather than a literal `then`: a cordis - // fiber is thenable, and what the mount observes is only that awaiting it - // settles. - return Object.assign(Promise.resolve({}), { - dispose: () => { - // The stand-in owns nothing that outlives the call. - }, - }); - }; - return ctx; - }; - - // Our own entry: a SIBLING of the host's `llm-pi-ai` entry, with its own id. - // That id is what a child mount inherits, and it is the namespace this plugin - // has already registered model discovery under. - const root = makeCtx(null, new Map(), ENTRY_ID); - root.logger = { - info: (msg: string, ...args: unknown[]) => { - observables.infos.push([msg, ...args].join(" ")); - }, - error: () => {}, - warn: () => {}, - }; - // The host's own entry, mounted on its own context under its own id. - const hostCtx = makeCtx(null, new Map(), "llm-pi-ai"); - - const registerService = (name: string, service: unknown) => { - realms.set(ROOT, new Map([...(realms.get(ROOT) ?? []), [name, service]])); - }; - - /** The LLM registry, with the host's own duplicate checks intact. */ - const adapters = new Map(); - const directory = new Map(); - const discoveries = new Map(); - const registerAdapter = (providers: readonly string[], adapter: unknown) => { - for (const route of providers) { - if (adapters.has(route)) { - throw new Error( - `an adapter for provider "${route}" is already registered` - ); - } - const info = ( - adapter as { providerInfo: (p: string) => { id: string; name: string } } - ).providerInfo(route); - adapters.set(route, info); - observables.adapters.push(route); - } - const dispose = () => { - for (const route of providers) { - adapters.delete(route); - } - }; - dispose.replace = (next: readonly string[]) => { - dispose(); - return registerAdapter(next, adapter); - }; - return dispose; - }; - const registerConfigurableProviders = (entries: readonly unknown[]) => { - for (const entry of entries) { - const row = entry as { provider: string }; - if (directory.has(row.provider)) { - throw new Error( - `configurable provider "${row.provider}" is already declared` - ); - } - } - for (const entry of entries) { - const row = entry as { provider: string }; - directory.set(row.provider, entry); - observables.directory.push(row.provider); - } - const dispose = () => { - for (const entry of entries) { - directory.delete((entry as { provider: string }).provider); - } - }; - dispose.replace = () => {}; - return dispose; - }; - const registerModelDiscovery = (ns: string, discover: unknown) => { - if (discoveries.has(ns)) { - throw new Error(`model discovery for "${ns}" is already registered`); - } - discoveries.set(ns, discover); - observables.discoveries.push(ns); - return () => discoveries.delete(ns); - }; - - registerService("llm", { - // The real service keeps its routes in an own map, and that map is the - // authority this plugin reads — `listProviders()` is Remote-decorated and - // under-reports. Exposed so a case can assert on the difference, and so a - // case can stage a registration that arrives after the declared check. - adapters, - listConfigurableProviders: () => [...directory.values()], - // What the deployment declared, which the mount narrows each route to. The - // real service answers it from the user's configuration; the catalog here - // stands in for that, so the mount has a declaration to read. - listModels: async () => [ - ...getLiveZenCatalog().map((spec) => ({ id: spec.id })), - ...getLiveGoCatalog().map((spec) => ({ id: spec.id })), - ], - listProviders: () => [...adapters.values()], - registerAdapter, - registerConfigurableProviders, - registerModelDiscovery, - }); - registerService("authorization", { - registerFlow: (key: string) => { - if (observables.authFlows.includes(key)) { - throw new Error( - `an authorization flow for "${key}" is already registered` - ); - } - observables.authFlows.push(key); - }, - }); - registerService("settings", { - configure: () => {}, - }); - - /** - * A stand-in for `llm-pi-ai`'s `apply`. - * - * Faithful to the parts that collide: the WHOLE catalog reaches the directory, - * the settings namespace is the inherited entry id, the flows are registered - * through the `authorization` inject, and the adapter is registered last. - */ - const apply = (ctx: HostCtx, config: unknown) => { - const declared = (config as { providers: unknown }).providers; - const providers = ( - declared instanceof ValidatedProviders ? declared.get() : declared - ) as Record; - ctx.inject(["authorization"], (scope: Record) => { - for (const provider of CATALOG) { - ( - scope.authorization as { registerFlow: (k: string) => void } - ).registerFlow(`${provider}-login`); - } - }); - const settingsNs = (ctx.fiber as { entry: { options: { id: string } } }) - .entry.options.id; - const llm = ctx.llm as { - registerAdapter: typeof registerAdapter; - registerConfigurableProviders: typeof registerConfigurableProviders; - registerModelDiscovery: typeof registerModelDiscovery; - }; - llm.registerConfigurableProviders( - [...CATALOG, ...Object.keys(providers)].map((provider) => ({ - provider, - displayName: provider, - settingsNs, - })) - ); - llm.registerModelDiscovery(settingsNs, async () => []); - const routes = Object.keys(providers); - if (routes.length > 0) { - llm.registerAdapter(routes, { - providerInfo: (route: string) => ({ id: route, name: route }), - }); - } - }; - - /** - * The stand-in's `Config`. - * - * It returns what schemastery returns: an object whose `providers` is a Dict - * INSTANCE, not a plain record. That is the whole reason the mount must be - * handed raw config — the registry keys its runtime by `apply` identity, so the - * second mount is validated by the first instance's schema, and a - * pre-validated object is not a shape that schema accepts. - */ - class ValidatedProviders { - private readonly entries: Record; - constructor(entries: Record) { - this.entries = entries; - } - get(): Record { - return this.entries; - } - } - const isPlainRecord = (value: unknown): boolean => - value !== null && - typeof value === "object" && - !Array.isArray(value) && - Object.getPrototypeOf(value) === Object.prototype; - const Config = (raw: unknown) => { - if ( - !isPlainRecord(raw) || - !isPlainRecord((raw as { providers: unknown }).providers) - ) { - // The host's own complaint, verbatim: a validated `Config` is a Dict - // instance, so its `providers` reads as a `.get` call, not a record. - throw new Error("llm-pi-ai: providers.get expected object"); - } - const { providers } = raw as { providers: Record }; - return { providers: new ValidatedProviders(providers) }; - }; - - // The REAL adapter class, so the bridge registers through the same class - // the host would hand it — a stand-in here would test our stand-in. - const piAi = { Config, apply, inject: undefined, name: PI_AI, PiAiAdapter }; - - /** The host's OWN instance: occupies the catalog, its namespace and the flows. */ - const mountHostInstance = (config: unknown) => - (hostCtx.plugin as (p: unknown, c?: unknown) => unknown)(piAi, config); - - /** - * Mount `apply` below ONE isolated seam and nothing else — the shape the - * earlier implementation used, which each case pairs with the seam that lets - * its collision be the one under test. - */ - const mountIsolated = (seam: string, config: unknown) => { - const scope = root.isolate(seam); - return (scope.plugin as (p: unknown, c?: unknown) => unknown)(piAi, config); - }; - - const loaderEntry = { options: { name: PI_AI }, moduleNamespace: piAi }; - const ctx = Object.assign(root, { - loader: { - entries: () => [loaderEntry], - // The loader's own normalization, so the shape a test asserts on is the - // shape the host produces rather than one this file invented. - unwrapExports: (value: unknown) => { - const candidate = value as { - default?: unknown; - __esModule?: boolean; - } | null; - if (candidate === null || candidate === undefined) { - return candidate; - } - const unwrapped = candidate.default ?? candidate; - return (unwrapped as { __esModule?: boolean }).__esModule === true - ? ((unwrapped as { default?: unknown }).default ?? unwrapped) - : unwrapped; - }, - }, - }) as unknown as CordisContext; - - return { - adapters, - ctx, - mountHostInstance, - mountIsolated, - observables, - piAi, - registerService, - }; -}; - -describe("responses-provider: registration is best-effort", () => { - it("stands down when the Host has no adapter registry", async () => { - const ctx = { llm: {}, logger: {} } as unknown as CordisContext; - await expect(registerResponsesProvider(ctx)).resolves.toBeUndefined(); - }); - - it("does not throw when the host loaded no copy of the plugin", async () => { - // `llm-pi-ai` is a profile bundle, so a host need not have it loaded at all. - // A deployment that does not must degrade to "the route you declared still - // works", not fail the boot — and must say so at INFO, because that is an - // expected state rather than a fault. - // The report goes to the CONSOLE, not `ctx.logger`: cordis's logger only - // buffers (its default exporter pushes to a ring) and no host package - // registers a console exporter, so a `logger.info` here is invisible in the - // terminal — which is where someone checks whether a build loaded. - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const ctx = { - llm: { listProviders: () => [], registerAdapter: vi.fn() }, - loader: { entries: () => [] }, - logger: {}, - } as unknown as CordisContext; - try { - await expect(registerResponsesProvider(ctx)).resolves.toBeUndefined(); - expect(ctx.llm?.registerAdapter).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith( - expect.stringContaining("no loaded llm-pi-ai") - ); - } finally { - info.mockRestore(); - } - }); - - it("defers to routes the profile already declares", async () => { - const plugin = vi.fn(); - const ctx = { - isolate: () => ({ plugin }), - llm: { - listProviders: () => [ - { id: "opencode" }, - { id: "opencode-responses" }, - { id: "opencode-anthropic" }, - ], - registerAdapter: vi.fn(), - }, - logger: {}, - } as unknown as CordisContext; - await expect(registerResponsesProvider(ctx)).resolves.toBeUndefined(); - expect(plugin).not.toHaveBeenCalled(); - }); - - it("reads the raw route map, not the decorated provider list", async () => { - // Measured: `listProviders()` is Remote-decorated and omitted a route the - // registry held, so a mount tried to re-register it and died on - // DUPLICATE_ADAPTER. Here every internal route is in the raw map and NONE - // is in the decorated list — that discrepancy, inverted into an assertion: - // reading the decorated list would mount, reading the map stands down. - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const plugin = vi.fn(); - const internal = Object.values(ROUTE_FOR_PLANE_PROTOCOL).flatMap((plane) => - Object.values(plane.routes) - ); - const ctx = { - isolate: () => ({ plugin }), - llm: { - adapters: new Map(internal.map((route) => [route, { id: route }])), - listProviders: () => [], - registerAdapter: vi.fn(), - }, - logger: {}, - } as unknown as CordisContext; - try { - await expect(registerResponsesProvider(ctx)).resolves.toBeUndefined(); - expect(plugin).not.toHaveBeenCalled(); - expect(ctx.llm?.registerAdapter).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith( - expect.stringContaining("every internal route is already declared") - ); - } finally { - info.mockRestore(); - } - }); - - it("stands down when the host offers no scope to mount into", async () => { - // A route that declares nothing has nothing to mount, so the mount stands - // down before it ever looks for a scope — and registering an adapter anyway - // is what used to put the catalog on a route the user had not filled. - const ctx = { - llm: { - listModels: async () => [], - listProviders: () => [], - registerAdapter: vi.fn(), - }, - logger: { info: () => {}, warn: () => {} }, - loader: { - entries: () => [ - { - options: { name: PI_AI }, - moduleNamespace: { apply: () => {}, name: PI_AI }, - }, - ], - unwrapExports: (value: unknown) => value, - }, - } as unknown as CordisContext; - await expect(registerResponsesProvider(ctx)).resolves.toBeUndefined(); - expect(ctx.llm?.registerAdapter).not.toHaveBeenCalled(); - }); -}); - -describe("responses-provider: finding the host's plugin", () => { - it("reads the module off the loader entry, not a guessed path", () => { - // `moduleNamespace` is the host's own import result, so nothing here knows - // or cares where the package was installed. - const { ctx, piAi } = createHost(); - expect(loadPiAi(ctx)).toBe(piAi); - }); - - it("normalizes the export shape the way the loader does", () => { - const { ctx, piAi } = createHost(); - // CJS interop: the namespace carries the plugin under `default`, which is - // exactly the shape the loader's own normalization exists for. - const wrapped = { default: piAi }; - (ctx.loader as { entries: () => unknown[] }).entries = () => [ - { options: { name: PI_AI }, moduleNamespace: wrapped }, - ]; - expect(loadPiAi(ctx)).toBe(piAi); - }); - - it("reports an entry that never finished loading as absent", () => { - const { ctx, piAi } = createHost(); - (ctx.loader as { entries: () => unknown[] }).entries = () => [ - { options: { name: PI_AI } }, - ]; - expect(loadPiAi(ctx)).toBeUndefined(); - expect(piAi).toBeDefined(); - }); - - it("reads the plugin off the fiber when the loader kept no module", () => { - // The other loader generation hands the import result to the registry and - // keeps only the fiber, so the two pieces a second mount needs are read off - // it: the resolved callback on its runtime, and the registry-normalized - // `inject` — without which the plugin's first service access fails. - const { ctx } = createHost(); - const callback = () => {}; - (ctx.loader as { entries: () => unknown[] }).entries = () => [ - { - fiber: { inject: { llm: null }, runtime: { callback } }, - options: { name: PI_AI }, - }, - ]; - expect(loadPiAi(ctx)).toEqual({ apply: callback, inject: { llm: null } }); - }); - - it("reports a fiber entry that carries nothing mountable as absent", () => { - const { ctx } = createHost(); - const fromEntry = (entry: unknown) => { - (ctx.loader as { entries: () => unknown[] }).entries = () => [entry]; - return loadPiAi(ctx); - }; - const options = { name: PI_AI }; - // Four separate missing pieces, each of which alone makes the mount - // impossible: the fiber itself, its runtime, the callback, and the inject. - expect(fromEntry({ options })).toBeUndefined(); - expect( - fromEntry({ fiber: { inject: { llm: null } }, options }) - ).toBeUndefined(); - expect( - fromEntry({ fiber: { inject: { llm: null }, runtime: {} }, options }) - ).toBeUndefined(); - expect( - fromEntry({ fiber: { runtime: { callback: () => {} } }, options }) - ).toBeUndefined(); - }); - - it("ignores an entry belonging to a different package", () => { - const { ctx, piAi } = createHost(); - (ctx.loader as { entries: () => unknown[] }).entries = () => [ - { options: { name: "some-other-plugin" }, moduleNamespace: piAi }, - ]; - expect(loadPiAi(ctx)).toBeUndefined(); - }); - - it("stands down when the host exposes no loader at all", () => { - expect(loadPiAi({})).toBeUndefined(); - expect(loadPiAi({ loader: { entries: 1 } })).toBeUndefined(); - expect(loadPiAi(null)).toBeUndefined(); - }); - - it("resolves the module from the base URL when the entry kept no namespace", async () => { - // The fiber-only generation keeps no namespace, and the class the bridge - // needs lives on one — so the entry's own name is resolved from the base - // URL the loader resolves against, which reaches the same file. - const { ctx } = createHost(); - (ctx.loader as { entries: () => unknown[] }).entries = () => [ - { - fiber: { inject: { llm: null }, runtime: { callback: () => {} } }, - options: { name: PI_AI }, - }, - ]; - // This repository's own tree, so the specifier resolves to the installed - // package — the same file the loader would have imported. - (ctx as { baseUrl?: string }).baseUrl = new URL( - "../", - import.meta.url - ).href; - const namespace = await loadPiAiModule(ctx); - // The class the module hands back is the installed package's own — the - // very one this file imported statically, not a copy of it. - expect(namespace?.PiAiAdapter).toBe(PiAiAdapter); - }); - - it("stands down without an entry, a base URL, or a resolvable module", async () => { - const base = new URL("../", import.meta.url).href; - const fiberEntry = { - fiber: { inject: { llm: null }, runtime: { callback: () => {} } }, - options: { name: PI_AI }, - }; - // No entry names the package: nothing to import, even from a good base. - const unnamed = createHost(); - (unnamed.ctx.loader as { entries: () => unknown[] }).entries = () => []; - (unnamed.ctx as { baseUrl?: string }).baseUrl = base; - expect(await loadPiAiModule(unnamed.ctx)).toBeUndefined(); - // An entry, but no base URL to resolve the loader's specifier against. - const unbased = createHost(); - (unbased.ctx.loader as { entries: () => unknown[] }).entries = () => [ - fiberEntry, - ]; - expect(await loadPiAiModule(unbased.ctx)).toBeUndefined(); - // A base URL the package cannot be resolved from. - const unresolved = createHost(); - (unresolved.ctx.loader as { entries: () => unknown[] }).entries = () => [ - fiberEntry, - ]; - (unresolved.ctx as { baseUrl?: string }).baseUrl = - "file:///nonexistent-dsh-opencode/"; - expect(await loadPiAiModule(unresolved.ctx)).toBeUndefined(); - }); - - it("hands the bridge a host carrying the adapter class either way", async () => { - // The namespace-keeping shape: the class is already on the record. - const kept = createHost(); - expect(await loadPiAiAdapterHost(kept.ctx)).toBe(kept.piAi); - // The fiber-only shape: the record carries no class, so the module is - // resolved instead and its class is what the bridge gets. - const fiber = createHost(); - (fiber.ctx.loader as { entries: () => unknown[] }).entries = () => [ - { - fiber: { inject: { llm: null }, runtime: { callback: () => {} } }, - options: { name: PI_AI }, - }, - ]; - (fiber.ctx as { baseUrl?: string }).baseUrl = new URL( - "../", - import.meta.url - ).href; - const resolved = await loadPiAiAdapterHost(fiber.ctx); - expect(resolved?.PiAiAdapter).toBe(PiAiAdapter); - // Neither reaches it: the mount's own record comes back, so the bridge - // stands down on the missing class rather than on a missing host. - const bare = createHost(); - const callback = () => {}; - (bare.ctx.loader as { entries: () => unknown[] }).entries = () => [ - { - fiber: { inject: { llm: null }, runtime: { callback } }, - options: { name: PI_AI }, - }, - ]; - expect(await loadPiAiAdapterHost(bare.ctx)).toEqual({ - apply: callback, - inject: { llm: null }, - }); - }); -}); - -describe("responses-provider: the mount survives the host's own instance", () => { - it("registers the internal routes without colliding with anything", async () => { - const host = createHost(); - // The host's own `llm-pi-ai` instance: it owns the catalog directory, its - // own discovery namespace, and one auth flow per catalog provider. Our - // plugin also registers discovery under its own entry id, which is exactly - // the id a child mount INHERITS. - host.mountHostInstance({ - providers: { opencode: { api: "openai-completions" } }, - }); - host.ctx.llm?.registerModelDiscovery?.( - "dsh-opencode-patch", - async () => [] - ); - - const flowCountBefore = host.observables.authFlows.length; - const adaptersBefore = [...host.observables.adapters]; - - const stop = await registerResponsesProvider(host.ctx); - - // Registered: the routes this plugin owns, on the REAL service. - expect(typeof stop).toBe("function"); - expect(host.observables.adapters).toContain("opencode-responses"); - - // Untouched: the host's own catalog directory keeps exactly what it had. - // A second declaration would have thrown DUPLICATE_DIRECTORY. - expect(host.observables.directory).toEqual([...CATALOG, "opencode"]); - - // Untouched: one discovery per namespace and no more. The host's instance owns - // its own entry's namespace, this plugin owns its own, and the mount added - // neither — a second registration under the INHERITED `dsh-opencode-patch` - // is what would have thrown DUPLICATE_DISCOVERY. - expect(host.observables.discoveries).toEqual([ - "llm-pi-ai", - "dsh-opencode-patch", - ]); - - // Untouched: no new authorization flow, which would have thrown - // DUPLICATE_FLOW on the host's own `registerFlow`. - expect(host.observables.authFlows).toHaveLength(flowCountBefore); - - // The host's own routes still serve. - for (const route of adaptersBefore) { - expect(host.ctx.llm?.listProviders?.()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: route })]) - ); - } - }); - - it("drops a route that arrives after the check, instead of dying on it", async () => { - // The declared check and the registration are two reads of the same map, - // and registration is all-or-nothing: a route that appears in between — - // another plugin, a reload — would kill the whole forward. The facade - // re-reads the map at the registration point, so the late route is left to - // its owner and every other route still lands. - const host = createHost({ - onPlugin: () => { - host.adapters.set("opencode-responses", { - id: "opencode-responses", - name: "opencode-responses", - }); - }, - }); - // Pre-declared so the Gemini bridge stays out of this case's way: the - // route's own registration, not the mount's, and unrelated here. - for (const route of SELF_MOUNTED_ROUTES) { - host.adapters.set(route, { id: route, name: route }); - } - - const stop = await registerResponsesProvider(host.ctx); - - expect(typeof stop).toBe("function"); - // The three others landed, in the mount's own order. - const others = Object.values(ROUTE_FOR_PLANE_PROTOCOL).flatMap((plane) => - Object.values(plane.routes).filter( - (route) => - !SELF_MOUNTED_ROUTES.has(route) && route !== "opencode-responses" - ) - ); - expect(host.observables.adapters).toEqual(others); - // And the late route was not re-registered behind its owner's back. - expect(host.adapters.get("opencode-responses")).toEqual({ - id: "opencode-responses", - name: "opencode-responses", - }); - }); - - it("drops a registration whose routes are ALL taken, instead of dying on it", async () => { - // The sibling case taken to the end of its range: every route the wrapper - // declares is already served by the time it registers, so the forward - // drops the whole list rather than letting the registry's all-or-nothing - // throw take the mount down. It still answers with a handle-shaped value — - // the wrapper stores what `registerAdapter` returned and may call - // `replace` on it later — and the mount stands down with nothing to - // withdraw rather than half-registering. - const routes: string[] = []; - const host = createHost({ - onPlugin: (config) => { - routes.push( - ...Object.keys( - (config as { providers: Record }).providers - ) - ); - for (const route of routes) { - host.adapters.set(route, { id: route, name: route }); - } - }, - }); - // Pre-declared so the Gemini bridge stays out of this case's way: its - // registration is its own, and this case is about the wrapper's. - for (const route of SELF_MOUNTED_ROUTES) { - host.adapters.set(route, { id: route, name: route }); - } - - const stop = await registerResponsesProvider(host.ctx); - - // The setup really did take every route — without this, the case could - // pass by exercising nothing. - expect(routes.length).toBeGreaterThan(0); - for (const route of routes) { - expect(host.adapters.has(route)).toBe(true); - } - // Nothing new landed: no route was re-registered behind its owner's back. - expect(host.observables.adapters).toEqual([]); - for (const route of routes) { - expect(host.adapters.get(route)).toEqual({ id: route, name: route }); - } - // The mount reported the stand-down, and handed back nothing to withdraw. - expect(host.observables.infos).toContain( - "[dsh-opencode-patch] the mount registered no route; internal routes stay unregistered" - ); - expect(stop).toBeUndefined(); - }); - - it("isolates both the authorization and the settings seam", async () => { - const host = createHost(); - await registerResponsesProvider(host.ctx); - // `settings` is the second one, and it is the one a test that only checked - // `authorization` would miss: without it the mounted instance's directory - // is driven by a section nobody asked to write. - expect(host.observables.isolated).toEqual(["authorization", "settings"]); - }); - - it("shadows the llm service for the mount only", async () => { - const host = createHost(); - await registerResponsesProvider(host.ctx); - // One `extend`, carrying exactly the service it needs to shadow. - expect(host.observables.extended).toEqual(["llm"]); - // The parent's own service is untouched by the shadow. - expect(typeof host.ctx.llm?.registerConfigurableProviders).toBe("function"); - }); - - it("passes RAW config, which is the only shape the recorded schema accepts", async () => { - const host = createHost(); - // A pre-validated `Config` is a Dict instance: the registry re-validates the - // second mount against the first instance's schema, and that object reads as - // `providers.get` rather than as a record. - expect(() => host.piAi.Config(host.piAi.Config({ providers: {} }))).toThrow( - /providers\.get expected object/ - ); - // So the mount has to hand over a plain object, and it does — reaching here - // at all means the schema accepted it. - await expect(registerResponsesProvider(host.ctx)).resolves.toBeTypeOf( - "function" - ); - }); - - it("carries every internal route in ONE config", async () => { - const spy = vi.fn(); - const host = createHost({ - onPlugin: (config) => { - spy(config); - }, - }); - - await registerResponsesProvider(host.ctx); - - // Each `apply` declares the whole catalog, so a per-route mount is a - // collision no matter how few routes it carries. One call, every route. - expect(spy).toHaveBeenCalledTimes(1); - const [config] = spy.mock.calls[0] ?? []; - const { providers } = (config ?? {}) as { - providers: Record; - }; - // Derived from the PLANE table, not hard-coded, and not from the Zen-only - // view: the mount walks (plane, protocol) pairs, so a route the table names - // is mounted whenever its own plane carries a model for its protocol. - // Self-mounted routes are NOT here: the wrapper's mount would only earn - // its protocol table's refusal, so the bridge registers them itself — - // which is the companion assertion. - const expected = Object.values(ROUTE_FOR_PLANE_PROTOCOL).flatMap((plane) => - Object.values(plane.routes).filter( - (route) => !SELF_MOUNTED_ROUTES.has(route) - ) - ); - - expect(Object.keys(providers)).toEqual(expected); - expect(Object.keys(providers)).toContain("opencode-responses"); - expect(Object.keys(providers)).not.toContain(GOOGLE_INTERNAL_ROUTE); - for (const profile of Object.values(providers)) { - expect(profile.models.length).toBeGreaterThan(0); - } - }); - - it("mounts each route on its PROTOCOL's base, not the plane's", async () => { - // The bug this guards, measured on the gateway: pi hands an Anthropic - // route's base straight to the Anthropic SDK, which appends `/v1/messages` - // itself, so the plane's `/v1` base asks for `/v1/v1/messages` — a 404. - // The OpenAI protocols append their own path to a base that already has - // `/v1`, so those keep it. One base for every route is wrong for at least - // one protocol. - const spy = vi.fn(); - const host = createHost({ - onPlugin: (config) => { - spy(config); - }, - }); - - await registerResponsesProvider(host.ctx); - - const [config] = spy.mock.calls[0] ?? []; - const { providers } = (config ?? {}) as { - providers: Record; - }; - - for (const [route, profile] of Object.entries(providers)) { - expect( - profile.baseURL, - `${route} (${profile.api}) is mounted on the wrong base` - ).toBe(baseForProtocol(routePlaneOf(route), profile.api)); - // And the positive control: the bases are not all the same string, which - // is what makes this assert something rather than restate the mount. - expect(profile.baseURL.length).toBeGreaterThan(0); - } - expect( - new Set(Object.values(providers).map((profile) => profile.baseURL)).size - ).toBeGreaterThan(1); - // Spot-check the two that differ, in the gateway's own terms. - expect(providers["opencode-anthropic"]?.baseURL).toBe( - "https://opencode.ai/inference/anthropic" - ); - expect(providers["opencode-responses"]?.baseURL).toBe( - OPENCODE_PLANE_API.zen - ); - }); - - it("releases the mount on dispose", async () => { - const host = createHost(); - const stop = await registerResponsesProvider(host.ctx); - expect(host.ctx.llm?.listProviders?.()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "opencode-responses" }), - ]) - ); - stop?.(); - // The route must NOT outlive the mount: a leftover registration would make - // the next mount fail DUPLICATE_ADAPTER, which is a reload loop that never - // recovers. The mount's own fiber would normally own this registration — - // releasing it here regardless is what makes withdrawal deterministic. - expect(host.ctx.llm?.listProviders?.()).toEqual([]); - }); -}); - -/** - * The stand-in is only worth anything if it REFUSES what used to be passed. - * - * Each case below mounts `apply` the way the earlier shape did and asserts the - * stand-in rejects it with the host's own error. Without these, the passing - * mount above could be passing because the stand-in models nothing. - */ -/** - * Which plane a mounted route serves, read back from the route table rather - * than guessed from the route's name. - */ -const routePlaneOf = (route: string): string => - Object.entries(ROUTE_FOR_PLANE_PROTOCOL).find(([, entry]) => - Object.values(entry.routes).includes(route) - )?.[0] ?? ""; - -describe("responses-provider: the stand-in refuses the shapes that used to be passed", () => { - const rawConfig = () => ({ - providers: { - "opencode-responses": { - api: "openai-responses", - models: [ - { - id: "m", - name: "m", - contextWindow: 1, - maxTokens: 1, - input: ["text"], - }, - ], - }, - }, - }); - - it("refuses a pre-validated Config handed to a second mount", () => { - const host = createHost(); - host.mountHostInstance({ providers: {} }); - // The registry reuses the first instance's schema, so the second mount is - // validated by it — and a validated Config is a Dict instance, not a record. - expect(() => - (host.ctx.plugin as (p: unknown, c?: unknown) => unknown)( - host.piAi, - host.piAi.Config(rawConfig()) - ) - ).toThrow(/providers\.get expected object/); - }); - - it("refuses a second instance's catalog declaration", () => { - const host = createHost(); - host.mountHostInstance({ providers: {} }); - // Isolating `authorization` alone does not help: `apply` declares the whole - // catalog regardless of what its own config names. - expect(() => host.mountIsolated("authorization", rawConfig())).toThrow( - /configurable provider "openai" is already declared/ - ); - }); - - it("refuses a second instance's discovery under the inherited namespace", () => { - const host = createHost(); - // Our plugin already registers discovery under its own entry id, and a child - // plugin inherits that entry — so the mount lands on the same key. - host.ctx.llm?.registerModelDiscovery?.( - "dsh-opencode-patch", - async () => [] - ); - // With the directory already colliding first, isolate only `settings` so this - // case fails on the discovery key and not on the one before it. - expect(() => host.mountIsolated("settings", rawConfig())).toThrow( - /model discovery for "dsh-opencode-patch" is already registered/ - ); - }); - - it("refuses a second instance's authorization flows", () => { - const host = createHost(); - host.mountHostInstance({ providers: {} }); - const flowsBefore = host.observables.authFlows.length; - expect(flowsBefore).toBeGreaterThan(0); - // With the seams that collide BEFORE the flows already dealt with, this one - // isolates `llm` only, so the inject still reaches the real authorization. - expect(() => host.mountIsolated("unused-seam", rawConfig())).toThrow( - /authorization flow .* is already registered/ - ); - }); -}); - -describe("responses-provider: the credential is the user's, not ours", () => { - it("inherits the reference the opencode route already declares", () => { - // A deployment that named its own env var must not be asked to state it - // again here: pi-ai resolves the reference through the credentials service, - // so the same ref is the same stored record. - const ctx = { - loader: { - entries: () => [ - { - options: { - id: "llm-pi-ai", - config: { providers: { opencode: { apiKeyEnv: "MY_ZEN_KEY" } } }, - }, - }, - ], - }, - }; - expect(inheritedCredentialRef(ctx)).toBe("MY_ZEN_KEY"); - }); - - it("falls back to the documented default when the composition names none", () => { - expect(inheritedCredentialRef({})).toBe("OPENCODE_API_KEY"); - expect( - inheritedCredentialRef({ - loader: { entries: () => [{ options: { config: {} } }] }, - }) - ).toBe("OPENCODE_API_KEY"); - }); - - it("puts the inherited reference on every route it mounts", async () => { - const spy = vi.fn(); - const host = createHost({ - onPlugin: (config) => { - spy(config); - }, - }); - // The composition's own `llm-pi-ai` row, alongside the entry this plugin - // reads its module from. - (host.ctx.loader as { entries: () => unknown[] }).entries = () => [ - { - options: { - id: "llm-pi-ai", - config: { providers: { opencode: { apiKeyEnv: "MY_ZEN_KEY" } } }, - }, - }, - { options: { name: PI_AI }, moduleNamespace: host.piAi }, - ]; - - await registerResponsesProvider(host.ctx); - - const [config] = spy.mock.calls[0] ?? []; - const { providers } = (config ?? {}) as { - providers: Record; - }; - // Each plane carries its OWN credential. One reference for every route was - // the old design, and it would send a Zen key to the Go endpoint - the - // failure the plane split exists to make impossible. - expect(Object.keys(providers).length).toBeGreaterThan(0); - const goRoutes = new Set([ - "opencode-go-responses", - "opencode-go-anthropic", - ]); - for (const [route, profile] of Object.entries(providers)) { - expect(profile.apiKeyEnv).toBe( - goRoutes.has(route) ? "OPENCODE_GO_API_KEY" : "MY_ZEN_KEY" - ); - } - }); -}); - -describe("responses-provider: the loader contract", () => { - const PI_AI_NAME = "@deepseek-ai/dsh-llm-pi-ai"; - // A body rather than `() => undefined`: these cases never run the plugin, and - // an arrow whose whole body is `undefined` says the return value matters. - const plugin = { - apply: (): void => { - /* never invoked by these cases */ - }, - name: PI_AI_NAME, - }; - - it("reads entries from a generator, which is what the loader yields", () => { - // `entries()` is a generator, not an array; a stand-in that returned an - // array would never exercise this path. - function* entries() { - yield { options: { name: "other" }, moduleNamespace: {} }; - yield { options: { name: PI_AI_NAME }, moduleNamespace: plugin }; - } - expect(loadPiAi({ loader: { entries } })).toBe(plugin); - }); - - it("reaches the loader through the service registry when it is not a property", () => { - const ctx = { - get: (name: string) => - name === "loader" - ? { - entries: () => [ - { options: { name: PI_AI_NAME }, moduleNamespace: plugin }, - ], - } - : undefined, - }; - expect(loadPiAi(ctx)).toBe(plugin); - }); - - it("accepts the raw namespace when the loader does not normalize exports", () => { - const ctx = { - loader: { - entries: () => [ - { options: { name: PI_AI_NAME }, moduleNamespace: plugin }, - ], - }, - }; - expect(loadPiAi(ctx)).toBe(plugin); - }); - - it("reports a namespace that carries no apply as absent", () => { - const ctx = { - loader: { - entries: () => [ - { - options: { name: PI_AI_NAME }, - moduleNamespace: { name: PI_AI_NAME }, - }, - ], - }, - }; - expect(loadPiAi(ctx)).toBeUndefined(); - }); - - it("survives a loader whose entries() throws", () => { - const ctx = { - loader: { - entries: () => { - throw new Error("loader unavailable"); - }, - }, - }; - expect(loadPiAi(ctx)).toBeUndefined(); - expect(inheritedCredentialRef(ctx)).toBe("OPENCODE_API_KEY"); - }); - - it("ignores entries that carry no options, no config, or no source route", () => { - const ctx = { - loader: { - entries: () => [ - null, - { options: null }, - { options: { config: null } }, - { options: { config: { providers: null } } }, - { options: { config: { providers: { opencode: null } } } }, - { - options: { config: { providers: { opencode: { apiKeyEnv: "" } } } }, - }, - { - options: { - config: { providers: { opencode: { apiKeyEnv: "REAL_KEY" } } }, - }, - }, - ], - }, - }; - expect(inheritedCredentialRef(ctx)).toBe("REAL_KEY"); - }); - - it("lists each model once when both planes carry it", () => { - const shared = { - context_window: 1, - id: "shared-model", - input_modalities: ["text"], - max_output_tokens: 1, - name: "Shared", - provider_npm: "@ai-sdk/openai", - }; - const catalog = [ - shared, - { ...shared }, - { ...shared, id: "other", provider_npm: undefined }, - ]; - const models = modelsForSdk("@ai-sdk/openai", catalog); - expect(models.map((m) => m.id)).toEqual(["shared-model"]); - }); -}); - -describe("responses-provider: the loader's degraded shapes", () => { - // Each of these is a shape a real host can hand over — a registry that throws, - // an `entries()` that yields something that is not a list — and each has to - // degrade to "no internal route", never to a failed boot. - - it("treats an entries() that yields a primitive as no entries", () => { - // `entries` is typed as an array but is a generator in practice; a host that - // returned a scalar would otherwise iterate its characters. - expect(loadPiAi({ loader: { entries: () => 42 } })).toBeUndefined(); - }); - - it("treats an entries() that yields a non-iterable object as no entries", () => { - expect(loadPiAi({ loader: { entries: () => ({}) } })).toBeUndefined(); - }); - - it("treats a loader without a usable entries() as absent", () => { - expect(loadPiAi({ loader: {} })).toBeUndefined(); - expect(loadPiAi({ loader: { entries: "not a function" } })).toBeUndefined(); - }); - - it("survives a service registry whose get() throws", () => { - // The registry is another plugin's service; asking it is not guaranteed to - // work, and a throw here must not take the boot with it. - const ctx = { - get: () => { - throw new Error("registry unavailable"); - }, - }; - expect(loadPiAi(ctx)).toBeUndefined(); - // The credential reference still answers with its documented default. - expect(inheritedCredentialRef(ctx)).toBe("OPENCODE_API_KEY"); - }); - - it("ignores a loader the registry resolves to something that is not one", () => { - expect(loadPiAi({ get: () => "not a loader" })).toBeUndefined(); - expect(loadPiAi({ get: () => null })).toBeUndefined(); - expect( - loadPiAi({ - get: () => { - /* the registry holds no loader */ - }, - }) - ).toBeUndefined(); - }); - - it("treats a context that is not a context as absent", () => { - for (const ctx of [undefined, null, 42, "ctx"]) { - expect(loadPiAi(ctx)).toBeUndefined(); - expect(inheritedCredentialRef(ctx)).toBe("OPENCODE_API_KEY"); - } - }); -}); - -describe("responses-provider: a plane serves only its own catalog", () => { - it("offers no model for a protocol its plane does not carry", () => { - // The Go plane has no Mistral model. Asking for one must answer nothing - - // not fall back to the Zen catalog, which is how a Go route would end up - // offering a model the Go endpoint does not serve. - expect(modelsForSdk("@ai-sdk/mistral", getLiveGoCatalog())).toEqual([]); - expect( - modelsForSdk("@ai-sdk/openai", getLiveGoCatalog()).length - ).toBeGreaterThan(0); - }); -}); - -describe("responses-provider: a loader that breaks its own contract", () => { - it("mounts nothing when the loader cannot yield entries", async () => { - // The host's loader always exposes `entries`. A composition whose loader - // does not is a contract violation, and the mount must answer "nothing to - // do" rather than throw inside the caller's startup path. - const host = createHost(); - (host.ctx.loader as { entries: unknown }).entries = 42; - await expect(registerResponsesProvider(host.ctx)).resolves.toBeUndefined(); - }); -}); - -describe("responses-provider · an internal route never outgrows the declaration", () => { - const models = [ - { id: "claude-haiku-5-5" }, - { id: "claude-sonnet-5-5" }, - { id: "gpt-5.4" }, - ]; - - it("keeps only the models the deployment declared", () => { - // A mounted route's model list is what a picker shows. The whole protocol - // catalog there is how models nobody selected reached the screen, which is - // the bug this narrowing exists for. - expect(narrowToDeclared(models, new Set(["claude-haiku-5-5"]))).toEqual([ - { id: "claude-haiku-5-5" }, - ]); - }); - - it("falls back to the full list when the declaration cannot be read", () => { - // `undefined` is "the host could not answer", not "the user declared - // nothing": mounting nothing would break every deployment whose host does - // not expose the listing. - expect(narrowToDeclared(models)).toEqual(models); - }); - - it("mounts nothing when the declaration excludes this protocol entirely", () => { - // The caller skips the route on an empty list rather than registering a - // provider with no models. - expect(narrowToDeclared(models, new Set(["gpt-5.4"]))).toHaveLength(1); - }); -}); - -describe("responses-provider · declared models a missing route would strand", () => { - const zen = getLiveZenCatalog(); - const anthropic = zen.find( - (spec) => spec.provider_npm === "@ai-sdk/anthropic" - ); - - it("names the model and the protocol nothing serves", () => { - // The case: no declared provider for this protocol, and a model the user - // selected that speaks it. Silence would leave the model in the picker - // with every call failing. - if (anthropic === undefined) { - throw new Error("the zen catalog serves no anthropic model"); - } - const declared = new Map([["opencode", new Set([anthropic.id])]]); - expect(unroutedDeclaredModels(declared, new Set())).toEqual([ - `${anthropic.id} (opencode · anthropic-messages)`, - ]); - }); - - it("says nothing once a route serves that protocol", () => { - if (anthropic === undefined) { - throw new Error("the zen catalog serves no anthropic model"); - } - const declared = new Map([["opencode", new Set([anthropic.id])]]); - expect( - unroutedDeclaredModels(declared, new Set(["opencode:anthropic-messages"])) - ).toEqual([]); - }); - - it("skips a model the catalog does not describe", () => { - // Nothing is known about its protocol, and a guess here would warn about a - // model that works. - const declared = new Map([["opencode", new Set(["not-in-the-catalog"])]]); - expect(unroutedDeclaredModels(declared, new Set())).toEqual([]); - }); -}); - -describe("responses-provider · the keyless and unreadable declarations", () => { - it("mounts nothing when the host cannot list its models", async () => { - // No `listModels` means no declaration can be read. That is now the same - // answer as an empty one — nothing — because filling the gap with the - // catalog is what put 58 undeclared models on hidden routes. - const ctx = { - llm: { listProviders: () => [], registerAdapter: () => () => {} }, - loader: { entries: () => [] }, - logger: { info: () => {}, warn: () => {} }, - } as unknown as CordisContext; - await expect(registerResponsesProvider(ctx)).resolves.toBeUndefined(); - }); - - it("reads the declaration before deciding what to mount", async () => { - // The mount asks the host what the deployment declared; a host that answers - // is the ordinary case, and the answer narrows every route it mounts. - const listed: string[] = []; - const ctx = { - llm: { - listModels: async (route: string) => { - listed.push(route); - return [ - { id: "beam-free" }, - { id: "gpt-5.4" }, - { name: "no id here" }, - null, - ]; - }, - listProviders: () => [], - registerAdapter: () => () => {}, - }, - loader: { entries: () => [] }, - logger: { info: () => {}, warn: () => {} }, - } as unknown as CordisContext; - await expect(registerResponsesProvider(ctx)).resolves.toBeUndefined(); - // Both plane routes were asked, and an entry without an id was ignored. - expect(listed).toEqual(["opencode", "opencode-go"]); - }); - - it("does not throw when the listing fails", async () => { - // A host whose `listModels` throws is the same case as one that answers - // nothing: no declaration can be read, so nothing is mounted and the boot - // does not fail. Mounting the catalog instead is the override this removed. - const ctx = { - llm: { - listModels: () => { - throw new Error("not implemented"); - }, - listProviders: () => [], - registerAdapter: () => () => {}, - }, - loader: { entries: () => [] }, - logger: { info: () => {}, warn: () => {} }, - } as unknown as CordisContext; - await expect(registerResponsesProvider(ctx)).resolves.toBeUndefined(); - }); -}); - -describe("routing · a declared protocol beats the catalog's", () => { - it("routes by the declared protocol before the SDK's", () => { - // `modelRoutes` is the deployment saying it knows better than a bundled - // snapshot. It has to reach the redirect, not just the model object: a - // provider built with a route protocol dispatches on THAT protocol - // (`createProvider` in `dsh-llm-pi-ai` calls `input.api.stream`), so the only - // way to change which format is spoken is to hand the call to another route. - expect( - internalRouteFor( - "opencode", - "some-model", - "@ai-sdk/openai", - "anthropic-messages" - ) - ).toBe("opencode-anthropic"); - }); - - it("falls back to the SDK's protocol when nothing was declared", () => { - expect( - internalRouteFor("opencode", "claude-opus-5", "@ai-sdk/anthropic") - ).toBe("opencode-anthropic"); - }); -}); - -describe("responses-provider · declared models the catalog cannot describe", () => { - it("names an id the bundled snapshot does not carry", () => { - // The everyday cause is a release newer than the shim, or an id the vendor has - // since marked deprecated (the shim drops those). Either way the model is - // served from the deployment's configuration and this plugin adds no metadata, - // and saying so is the difference between "ignored" and "never seen". - const declared = new Map([ - ["opencode", new Set(["ll-alpha-free", "step-5-preview-free"])], - ]); - expect(undescribedDeclaredModels(declared)).toEqual([ - "ll-alpha-free (opencode)", - "step-5-preview-free (opencode)", - ]); - }); - - it("says nothing about ids the catalog knows", () => { - const [spec] = getLiveZenCatalog(); - if (spec === undefined) { - throw new Error("the zen catalog is empty"); - } - expect( - undescribedDeclaredModels(new Map([["opencode", new Set([spec.id])]])) - ).toEqual([]); - }); -}); - -describe("responses-provider · the declaration needs no credential", () => { - it("reads the declaration from the config, through the shared reader", async () => { - // The walk itself lives in `declaredProviderModels` (go-discovery) and is - // tested there; what this file owns is that the MOUNT asks for it at all — - // the credential-free source — rather than only `listModels`, which answers - // [] at boot because it resolves the route's key first. - // - // Measured in a live host: - // opencode: no models could be read for the route - it declares none, or - // its credential is not resolvable in this process - so no internal route - // is mounted for it - const ctx = { - loader: { - *entries() { - yield { - options: { - config: { - providers: { - opencode: { models: [{ id: "gpt-5.4" }, { id: "" }, null] }, - "opencode-go": { models: [{ id: "deepseek-v4.1-flash" }] }, - }, - }, - }, - }; - }, - }, - } as unknown as CordisContext; - expect(declaredProviderModels(ctx, "opencode")).toEqual([ - { id: "gpt-5.4" }, - ]); - expect(declaredProviderModels(ctx, "opencode-go")).toEqual([ - { id: "deepseek-v4.1-flash" }, - ]); - // A route the loader does not describe is UNDEFINED, not empty: "cannot - // read" and "declared nothing" must stay distinguishable. - expect(declaredProviderModels(ctx, "nvidia")).toBeUndefined(); - }); -}); - -describe("responses-provider · an empty declaration mounts nothing", () => { - it("mounts no internal route when the host answers [] for a route", async () => { - // `dsh-llm-pi-ai` answers `[]` from `listModels` when the route's credential - // cannot be resolved (`lib/index.js:1894`) — the keyless deployment this - // plugin supports. An earlier revision read that as "the declaration could - // not be read" and fell back to the whole catalog; measured on a live host, - // that mounted 58 models the deployment never declared: - // - // mounted opencode-responses … 30 model(s); opencode-anthropic … 18; - // opencode-go-responses … 5; opencode-go-anthropic … 5 - // - // A route's model list belongs to the operator, so `[]` now mounts nothing. - // Dispatch does not depend on these routes any more: `decoratePlaneAdapters` - // names each model's `api`, and `dsh-llm-pi-ai` dispatches per model. - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const registered: string[] = []; - const ctx = { - isolate: () => ({}), - llm: { - listModels: async () => [], - listProviders: () => [], - registerAdapter: (providers: readonly string[]) => { - registered.push(...providers); - return () => {}; - }, - }, - loader: { entries: () => [] }, - } as unknown as CordisContext; - try { - await registerResponsesProvider(ctx); - // Nothing declared, so nothing registered — the whole point. - expect(registered).toEqual([]); - // The report must not assert a cause it cannot see. `[]` means either "this - // route declares nothing" or "its credential did not resolve" — a route the - // deployment DID configure lands in the second case when the credential - // service is unreadable in this process, and announcing "no models declared" - // there sends the operator looking at a configuration that was never wrong. - const line = info.mock.calls - .map((call) => String(call[0])) - .find((text) => text.includes("no models could be read")); - expect(line).toBeDefined(); - expect(line).toContain("declares none"); - expect(line).toContain("credential is not resolvable"); - } finally { - info.mockRestore(); - } - }); -}); - -describe("Go Google bridge mounting", () => { - it("inherits a custom Go key independently of Zen", () => { - const ctx = { - loader: { - entries: () => [ - { - options: { - id: "llm-pi-ai", - config: { - providers: { - opencode: { apiKeyEnv: "ZEN_REF" }, - "opencode-go": { apiKeyEnv: "GO_REF" }, - }, - }, - }, - }, - ], - }, - }; - expect(inheritedCredentialRef(ctx, "opencode-go")).toBe("GO_REF"); - expect(inheritedCredentialRef(ctx)).toBe("ZEN_REF"); - expect(inheritedCredentialRef({}, "opencode-go")).toBe( - "OPENCODE_GO_API_KEY" - ); - }); - - it("mounts and withdraws both Google routes", async () => { - const host = createHost(); - const stop = await registerResponsesProvider(host.ctx); - expect(host.adapters.has("opencode-google")).toBe(true); - expect(host.adapters.has("opencode-go-google")).toBe(true); - stop?.(); - expect(host.adapters.has("opencode-google")).toBe(false); - expect(host.adapters.has("opencode-go-google")).toBe(false); - }); - - it("hands back the bridges' disposer when every internal route is already declared", async () => { - // The bridge mounts before the wrapper's routes are weighed, so an early - // exit that answered `undefined` left both Google routes registered with - // nothing able to withdraw them — and the next reload's bridge then failed - // DUPLICATE_ADAPTER. Every wrapper-mounted route is declared here, which is - // the exit that leaves nothing to mount. - const host = createHost(); - const wrapperRoutes = Object.values(ROUTE_FOR_PLANE_PROTOCOL) - .flatMap((plane) => Object.values(plane.routes)) - .filter((route) => !SELF_MOUNTED_ROUTES.has(route)); - expect(wrapperRoutes.length).toBeGreaterThan(0); - for (const route of wrapperRoutes) { - host.adapters.set(route, { id: route }); - } - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - try { - const stop = await registerResponsesProvider(host.ctx); - expect(info).toHaveBeenCalledWith( - expect.stringContaining("every internal route is already declared") - ); - expect(host.adapters.has("opencode-google")).toBe(true); - expect(host.adapters.has("opencode-go-google")).toBe(true); - expect(stop).toBeTypeOf("function"); - stop?.(); - expect(host.adapters.has("opencode-google")).toBe(false); - expect(host.adapters.has("opencode-go-google")).toBe(false); - // The disposer withdraws only what it mounted: the declared routes stay. - for (const route of wrapperRoutes) { - expect(host.adapters.has(route)).toBe(true); - } - // And a second activation can mount them again, which is what a reload does. - const again = await registerResponsesProvider(host.ctx); - expect(host.adapters.has("opencode-google")).toBe(true); - again?.(); - } finally { - info.mockRestore(); - } - }); - - it("withdraws routes and disposes the fiber when the mount fails to start", async () => { - // A route the mounted plugin registered before its startup error is live on - // the host. The failure path used to log "internal routes stay unregistered" - // and leave both the route and the fiber behind. - const host = createHost(); - const disposed = vi.fn(); - type Ctx = NonNullable; - const wrap = (ctx: Ctx): Ctx => { - const isolate = ctx.isolate as (name: string) => Ctx; - const plugin = ctx.plugin as ( - subject: unknown, - config?: unknown - ) => unknown; - Object.assign(ctx, { - isolate(this: Ctx, name: string) { - return wrap(isolate.call(this, name)); - }, - plugin(this: Ctx, subject: unknown, config?: unknown) { - plugin.call(this, subject, config); - return { - await: () => Promise.reject(new Error("startup failed")), - dispose: disposed, - }; - }, - }); - return ctx; - }; - wrap(host.ctx); - const stop = await registerResponsesProvider(host.ctx); - // Fiber disposal is fire-and-forget; let it run. - await Promise.resolve(); - expect(disposed).toHaveBeenCalledTimes(1); - // Only the two bridges remain; every route the failed mount took is gone. - expect( - ( - (host.ctx.llm?.listProviders?.() ?? []) as readonly { id: string }[] - ).map((provider) => provider.id) - ).toEqual(["opencode-google", "opencode-go-google"]); - // The bridges did mount, and the disposer handed back still withdraws them. - expect(host.adapters.has("opencode-google")).toBe(true); - stop?.(); - expect(host.adapters.has("opencode-google")).toBe(false); - }); - - it("withdraws the first bridge if mounting the second fails", async () => { - const host = createHost(); - const llm = host.ctx.llm!; - const original = llm.registerAdapter!; - llm.registerAdapter = (routes, adapter) => { - if (routes.includes("opencode-go-google")) - throw new Error("Go registration failed"); - return original.call(llm, routes, adapter); - }; - await expect(registerResponsesProvider(host.ctx)).rejects.toThrow( - "Go registration failed" - ); - expect(host.adapters.has("opencode-google")).toBe(false); - expect(host.adapters.has("opencode-go-google")).toBe(false); - }); -}); diff --git a/test/responses-routes.test.ts b/test/responses-routes.test.ts deleted file mode 100644 index 55d57c3c..00000000 --- a/test/responses-routes.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * `responses-routes.ts` — the dispatch decision for the gateway's Responses - * plane, derived from the vendor's per-model SDK rather than a list of ids. - */ - -import { readFileSync } from "node:fs"; - -import { describe, expect, it } from "vitest"; - -import { - ANTHROPIC_ROUTE, - ANTHROPIC_SDK, - GOOGLE_INTERNAL_ROUTE, - GOOGLE_SDK, - GO_RESPONSES_ROUTE, - MISTRAL_SDK, - OPENCODE_GO_CATALOG, - OPENCODE_ZEN_CATALOG, - RESPONSES_ROUTE, - RESPONSES_SDK, - ROUTE_FOR_PLANE_PROTOCOL, - catalogPlaneForRoute, - findModelSpec, - findModelSpecOn, - internalRouteFor, - isServableModel, - isServableSdk, - parseModelsDevCatalog, -} from "./support/src.ts"; - -const MUSE = "muse-spark-1.2"; - -describe("responses-routes: the SDK mapping", () => { - it("redirects a model that names the OpenAI SDK", () => { - // models.dev names `provider.npm` only as an OVERRIDE of the provider's - // default, so its presence is the signal. 32 of the 116 opencode models - // carry it; the hand-written list this replaced named one. - expect(internalRouteFor("opencode", "gpt-5", RESPONSES_SDK)).toBe( - RESPONSES_ROUTE - ); - expect(internalRouteFor("opencode", MUSE, RESPONSES_SDK)).toBe( - RESPONSES_ROUTE - ); - }); - - it("routes a hand-listed model by the family its id names", () => { - // A model the catalog has never heard of carries no SDK, and `undefined` - // reads as the completions default - right for a model that omits its SDK, - // wrong for one nobody has described, and indistinguishable here. Measured - // against the live gateway: gemini-3.8-flash and claude-sonnet-4-6 both answer - // 400 on the completions endpoint and 200 on their own. - expect(internalRouteFor("opencode", "gemini-3.1-pro")).toBe( - GOOGLE_INTERNAL_ROUTE - ); - expect(internalRouteFor("opencode", "claude-opus-4-7")).toBe( - ANTHROPIC_ROUTE - ); - // A family the rule does not name keeps the route it was given: a wrong route - // is worse than the one the deployment asked for. - expect(internalRouteFor("opencode", "kimi-k2.5")).toBeUndefined(); - // And a catalog SDK still wins, because it is the better answer. - expect(internalRouteFor("opencode", "claude-opus-4-7", RESPONSES_SDK)).toBe( - RESPONSES_ROUTE - ); - // muse-* and gpt-* models automatically route to Responses API even when no SDK is passed - expect(internalRouteFor("opencode", "gpt-5.4")).toBe(RESPONSES_ROUTE); - expect(internalRouteFor("opencode", "muse-spark-1.2")).toBe( - RESPONSES_ROUTE - ); - // A Go-only model (e.g. muse-spark-1.3-contributor) routes to the Go Responses route - expect(internalRouteFor("opencode", "muse-spark-1.3-contributor")).toBe( - GO_RESPONSES_ROUTE - ); - expect(internalRouteFor("opencode-go", "muse-spark-1.3-contributor")).toBe( - GO_RESPONSES_ROUTE - ); - }); - - it("redirects a model that names the Anthropic SDK too", () => { - // 23 of the 116 opencode models name @ai-sdk/anthropic, and llm-pi-ai - // implements anthropic-messages — so the same mechanism covers them. A - // Responses-only table would have left all 23 on the completions route. - expect( - internalRouteFor("opencode", "claude-sonnet-4-5", ANTHROPIC_SDK) - ).toBe(ANTHROPIC_ROUTE); - }); - - it("knows which SDKs it cannot serve", () => { - // @ai-sdk/google names google-generative-ai: the wrapper's hand-declared - // table refuses that protocol, so the plugin serves it through its own - // adapter instead. Servable, because the bridge route exists. - expect(isServableSdk()).toBe(true); - expect(isServableSdk(RESPONSES_SDK)).toBe(true); - expect(isServableSdk(ANTHROPIC_SDK)).toBe(true); - expect(isServableSdk(GOOGLE_SDK)).toBe(true); - // @ai-sdk/mistral is the decision, not the gap: the bridge pattern that - // would serve it is built and measured for Google, but this dialect at - // the gateway is unmeasured and no entitled key exists to measure it. - expect(isServableSdk(MISTRAL_SDK)).toBe(false); - }); - - it("leaves a model naming no SDK on its own route", () => { - // 53 models carry no override and speak the provider default. Omitting the - // SDK is how the catalog expresses that — absent, never a placeholder. - expect(internalRouteFor("opencode", "beam-free")).toBeUndefined(); - }); - - it("redirects a Google model to the route the plugin mounts itself", () => { - // The wrapper's table refuses google-generative-ai, so this dispatch - // lands on the bridge route — the one route this plugin registers - // through its own adapter rather than the wrapper's mount. - expect(internalRouteFor("opencode", "gemini-3-pro", GOOGLE_SDK)).toBe( - GOOGLE_INTERNAL_ROUTE - ); - expect(internalRouteFor("opencode-go", "gemini-4-argon", GOOGLE_SDK)).toBe( - "opencode-go-google" - ); - }); - - it("leaves an SDK with no protocol anywhere on its own route", () => { - // @ai-sdk/mistral names a protocol pi implements but this build does not - // serve, so it is in neither PROTOCOL_FOR_SDK nor a route: the dispatch - // declines rather than sending the model somewhere invented. - expect( - internalRouteFor("opencode", "mistral-large-4", MISTRAL_SDK) - ).toBeUndefined(); - }); - - it("does not redirect the redirected call again", () => { - // The redirected call re-enters the same hook with the target route already - // set. Redirecting that would recurse until the stack ran out. - expect( - internalRouteFor(RESPONSES_ROUTE, MUSE, RESPONSES_SDK) - ).toBeUndefined(); - }); - - it("ignores malformed options", () => { - expect(internalRouteFor(null, MUSE, RESPONSES_SDK)).toBeUndefined(); - expect(internalRouteFor("opencode", null, RESPONSES_SDK)).toBeUndefined(); - expect(internalRouteFor("opencode", MUSE, 42)).toBeUndefined(); - }); -}); - -describe("responses-routes: the catalog seam", () => { - it("reads the SDK out of the live parse", () => { - const parsed = parseModelsDevCatalog({ - opencode: { - models: { - overridden: { name: "A", provider: { npm: RESPONSES_SDK } }, - defaulted: { name: "B" }, - }, - }, - }); - const byId = new Map(parsed.zen.map((spec) => [spec.id, spec])); - expect(byId.get("overridden")?.provider_npm).toBe(RESPONSES_SDK); - // Absent must stay absent: undefined means "the route's own api". - expect(byId.get("defaulted")?.provider_npm).toBeUndefined(); - }); - - it("carries the SDK on the bundled shim, so a cold start still redirects", () => { - // The shim answers before the first live refresh. If it lost this field, a - // cold start would dispatch muse to the completions route and fail, with - // nothing pointing at the cause. - expect(findModelSpec(MUSE)?.provider_npm).toBe(RESPONSES_SDK); - }); - - it("redirects only to a route the plugin layer claims", () => { - // An unclaimed route gets no session header, no origin headers and no key - // injection, so the redirected call would 403 and look like a model problem. - const layer = readFileSync( - new URL("../cordis.patch.yml", import.meta.url), - "utf8" - ); - expect(layer).toContain(`- ${RESPONSES_ROUTE}`); - }); -}); - -describe("responses-routes: the SDK is read from the plane the request is on", () => { - /** - * The two planes declare DIFFERENT SDKs for the same model id, and reading the - * wrong one is a mis-route rather than a detail. - * - * models.dev's `opencode-go` names `@ai-sdk/anthropic` for `qwen3.8-max`, - * `minimax-m2.7` and `minimax-m3`; its `opencode` names nothing, i.e. the - * completions default. A Go-first lookup answered a ZEN question with GO data - * and sent all three to `/messages`. Measured 2026-10-06 against the live Zen - * gateway: `qwen3.8-max` and `minimax-m3` answer `200` on - * `/chat/completions` and `400 ModelProtocolUnsupported` on `/messages`. - * - * Nothing else in the suite could see this: every other routing case reads the - * same table the code reads, so a wrong PLANE looks exactly like a right one. - */ - // Derived, not listed. The ids this used to name were hard-coded, and the - // vendor deprecated `minimax-m2.7` on the Go plane - which made the case below - // fail on a model that was no longer there rather than on the property it was - // written to check. A computed set cannot go stale the same way: it is the - // models both planes currently carry, whose SDK the two planes disagree about. - const go = new Map(OPENCODE_GO_CATALOG.map((s) => [s.id, s.provider_npm])); - const zen = new Map(OPENCODE_ZEN_CATALOG.map((s) => [s.id, s.provider_npm])); - const DIVERGENT = [...go.keys()].filter( - (id) => - zen.has(id) && go.get(id) !== undefined && go.get(id) !== zen.get(id) - ); - // And the anti-vacuity guard it was always meant to be: if the planes ever stop - // disagreeing, the routing cases below stop proving anything. - expect( - DIVERGENT.length, - "no model's SDK differs between the planes any more" - ).toBeGreaterThan(0); - - it("has models whose SDK genuinely differs between the planes", () => { - // If this ever stops being true the cases below stop proving anything, and - // a silent skip would be worse than a failure. - for (const id of DIVERGENT) { - expect(go.get(id), `${id} is no longer on the Go plane`).toBe( - ANTHROPIC_SDK - ); - expect(zen.get(id), `${id} gained a Zen SDK`).toBeUndefined(); - } - }); - - it("routes each divergent model by the ZEN plane, not the Go one", () => { - for (const id of DIVERGENT) { - // The Go-first lookup — the bug — would have said "anthropic". - expect(findModelSpec(id)?.provider_npm, "Go plane").toBe(ANTHROPIC_SDK); - expect( - findModelSpecOn("zen", id)?.provider_npm, - "Zen plane" - ).toBeUndefined(); - // And the routing decision follows the plane, so a Zen request stays on - // the completions route where the gateway actually serves it. - expect( - internalRouteFor( - "opencode", - id, - findModelSpecOn(catalogPlaneForRoute("opencode"), id)?.provider_npm - ) - ).toBeUndefined(); - } - }); - - it("leaves a model the Zen plane really does split alone", () => { - // The fix must not flatten the split it exists to respect: these name their - // SDK on BOTH planes, and must still be redirected. - for (const [id, route] of [ - ["grok-4.7", RESPONSES_ROUTE], - ["claude-sonnet-4-5", ANTHROPIC_ROUTE], - ] as const) { - expect(findModelSpecOn("zen", id)?.provider_npm).toBeDefined(); - expect( - internalRouteFor( - "opencode", - id, - findModelSpecOn(catalogPlaneForRoute("opencode"), id)?.provider_npm - ) - ).toBe(route); - } - }); - - it("names the plane of each route the plugin knows", () => { - expect(catalogPlaneForRoute("opencode-go")).toBe("go"); - expect(catalogPlaneForRoute("opencode")).toBe("zen"); - // The internal routes are derived from the Zen one, so they are Zen too. - expect(catalogPlaneForRoute(RESPONSES_ROUTE)).toBe("zen"); - expect(catalogPlaneForRoute(ANTHROPIC_ROUTE)).toBe("zen"); - // An unknown route — and the absent case — is not silently treated as Go: - // Go is the exception, so it has to be named to be meant. - const absent: unknown = undefined; - expect(catalogPlaneForRoute("my-relay")).toBe("zen"); - expect(catalogPlaneForRoute(absent)).toBe("zen"); - }); -}); - -describe("responses-routes: every model the picker offers is dispatchable", () => { - it("resolves each servable catalog model to a route the plugin mounts", () => { - // The picker now publishes the whole catalog on the parent route (see - // `models-discovery` and the join), which is the first time the Responses, - // Messages and Google models have been visible there. Visibility without a - // route would be worse than absence: the user picks a model and gets - // "no adapter for provider". - // - // So: every model `isServableModel` admits must either stay on its own route - // (`undefined`) or name a route this plugin actually mounts. The mount set is - // the plane table plus the two plane roots — `opencode-google` is mounted by - // the Google bridge rather than the wrapper, and is in the table for exactly - // that reason. - const mounted = new Set(["opencode", "opencode-go"]); - for (const plane of Object.values(ROUTE_FOR_PLANE_PROTOCOL)) { - for (const route of Object.values(plane.routes)) { - mounted.add(route); - } - } - - const unroutable: string[] = []; - for (const [route, catalog] of [ - ["opencode", OPENCODE_ZEN_CATALOG], - ["opencode-go", OPENCODE_GO_CATALOG], - ] as const) { - for (const model of catalog) { - if (!isServableModel(model.id, route)) { - continue; - } - const target = internalRouteFor(route, model.id, model.provider_npm); - if (target !== undefined && !mounted.has(target)) { - unroutable.push(`${route}/${model.id} -> ${target}`); - } - } - } - expect(unroutable).toEqual([]); - }); -}); diff --git a/test/route-claim.test.ts b/test/route-claim.test.ts new file mode 100644 index 00000000..617c126c --- /dev/null +++ b/test/route-claim.test.ts @@ -0,0 +1,956 @@ +/** + * 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 + * 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`. + * + * @module test/route-claim.test + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { hostModulesForTests } from "./support/host.ts"; +import { + catalogListenerCount, + type CordisContext, + type HostModuleSource, + installRouteClaim, + refreshCatalog, + replaceHostModuleSource, + resolveConfig, +} from "./support/src.ts"; + +let restore: (() => void) | undefined; + +afterEach(() => { + restore?.(); + restore = undefined; + 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", () => { + it("leaves the planes to pi-ai on a host without the cordis members it needs, and says which", async () => { + const warn = vi.fn(); + const claim = installRouteClaim( + { logger: { warn } } as unknown as CordisContext, + config + ); + await expect(claim.mounted).resolves.toBeUndefined(); + expect(claim.committedEntries()).toEqual({}); + expect(claim.piAiNamespace()).toBe("llm-pi-ai"); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("offers no on, inject, effect") + ); + }); + + it("hands the planes back when the host's modules cannot be read, and says so", async () => { + useTestModules({ + libraries: async () => {}, + piAi: async () => {}, + }); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + expect(host.llm.registerAdapter).not.toHaveBeenCalled(); + expect(pi.restart).toHaveBeenCalledTimes(1); + expect(host.warn).toHaveBeenCalledWith( + expect.stringContaining("unavailable; the plane routes stay pi-ai's") + ); + // Inert from here: the listener passes through. + const next = vi.fn(() => "next"); + expect( + host.listeners.get("internal/config")?.call(pi, pi._config, next) + ).toBe("next"); + }); + + it("when the modules cannot be read at mount and pi-ai already holds a plane, it stands down with no restart at all", async () => { + useTestModules({ + libraries: async () => {}, + piAi: async () => {}, + }); + const pi = piFiber(undefined, { deepseek: {}, opencode: {} }); + const host = standIn({ fibers: () => [pi] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + // A restart would strip the planes through the still-active listener, + // and the handback would need a second to give them back. + expect(pi.restart).not.toHaveBeenCalled(); + expect(host.llm.registerAdapter).not.toHaveBeenCalled(); + const unavailable = host.warn.mock.calls.filter(([message]) => + String(message).includes("unavailable; the plane routes stay pi-ai's") + ); + expect(unavailable).toHaveLength(1); + const next = vi.fn(() => "next"); + expect( + host.listeners.get("internal/config")?.call(pi, pi._config, next) + ).toBe("next"); + }); + + it("with the modules unreadable and no plane declared, the claim stands down, says so exactly once, and a Settings save adding a plane reaches pi-ai's own check", async () => { + useTestModules({ + libraries: async () => {}, + piAi: async () => {}, + }); + const pi = piFiber({ deepseek: {} }); + const host = standIn({ fibers: () => [pi] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + host.listeners.get("llm/adapters-updated")?.(); + await flush(); + const unavailable = host.warn.mock.calls.filter(([message]) => + String(message).includes("unavailable; the plane routes stay pi-ai's") + ); + expect(unavailable).toHaveLength(1); + // Nothing was declared, so the handback had nothing to give back. + expect(pi.restart).not.toHaveBeenCalled(); + expect(host.llm.registerAdapter).not.toHaveBeenCalled(); + // The module lookup never recovers within a live row: an active claim + // would refuse this save with "try the save again" for good. Inert, it + // hands the candidate to pi-ai unstripped, whose own check decides. + const listener = host.listeners.get("internal/config"); + const next = vi.fn(() => "next"); + const candidate = { providers: { deepseek: {}, opencode: zen } }; + expect(listener?.call(pi, candidate, next)).toBe("next"); + // And the commit run that follows passes through untouched. + pi._config = candidate; + expect(listener?.call(pi, pi._config, next)).toBe("next"); + await flush(); + expect(host.llm.registerAdapter).not.toHaveBeenCalled(); + }); + + it("a stand-down after a registration releases the plugin's handle before pi-ai restarts", async () => { + const real = hostModulesForTests(); + let readable = true; + useTestModules({ + libraries: real.libraries, + piAi: async (...args) => (readable ? real.piAi(...args) : undefined), + }); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); + expect(pi.restart).not.toHaveBeenCalled(); + // pi-ai's entry is reloaded and its module can no longer be read. + readable = false; + host.listeners.get("loader/partial-dispose")?.({ fiber: pi }); + await flush(); + expect(host.handle).toHaveBeenCalledTimes(1); + expect(pi.restart).toHaveBeenCalledTimes(1); + const [released] = host.handle.mock.invocationCallOrder; + const [restarted] = vi.mocked(pi.restart).mock.invocationCallOrder; + expect(released).toBeLessThan(restarted ?? 0); + }); + + it("a failing handback restart is reported, not thrown", async () => { + useTestModules({ + libraries: async () => {}, + piAi: async () => {}, + }); + const pi = piFiber(); + pi.restart = vi.fn(async () => { + throw new Error("busy"); + }); + const host = standIn({ fibers: () => [pi] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + expect(host.warn).toHaveBeenCalledWith( + expect.stringContaining("did not restart to take the plane routes back"), + expect.any(Error) + ); + }); +}); + +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 · validating while the modules are unloaded", () => { + it("accepts a valid plane save in the window after pi-ai's partial dispose, and still refuses a malformed one in pi-ai's words", async () => { + useTestModules(); + const pi = piFiber(); + const host = standIn({ fibers: () => [pi] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); + // pi-ai's row is partially disposed (a profile reload that updated it): + // the claim drops the modules it serves with and reconciles on a + // microtask. A Settings save that lands before then is still judged. + host.listeners.get("loader/partial-dispose")?.({ fiber: pi }); + const listener = host.listeners.get("internal/config"); + const candidate = { + providers: { + deepseek: {}, + opencode: { models: [{ id: "deepseek-v4-pro" }, { id: "x" }] }, + }, + }; + expect(listener?.call(pi, candidate, () => "next")).toEqual({ + providers: { deepseek: {} }, + }); + expect(() => + listener?.call( + pi, + { providers: { opencode: { models: [{ id: "" }] } } }, + () => "next" + ) + ).toThrow(/has a model with an empty id/u); + await flush(); + }); +}); + +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(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"]); + // A commit that removes the last plane withdraws the routes through the + // live handle, replace([]), never by releasing it; committing the plane + // back moves it through the same handle, with no second registration. + pi._config = { providers: { deepseek: {} } }; + host.listeners.get("internal/config")?.call(pi, pi._config, () => "next"); + await flush(); + expect(host.handle.replace).toHaveBeenLastCalledWith([]); + expect(host.handle).not.toHaveBeenCalled(); + expect(claim.committedEntries()).toEqual({}); + pi._config = { providers: { deepseek: {}, opencode: zen } }; + host.listeners.get("internal/config")?.call(pi, pi._config, () => "next"); + await flush(); + expect(host.handle.replace).toHaveBeenLastCalledWith(["opencode"]); + expect(host.handle).not.toHaveBeenCalled(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(2); + // 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] }); + await installRouteClaim(host.ctx, config).mounted; + await flush(); + expect(host.llm.registerAdapter).toHaveBeenCalledTimes(1); + const [teardown] = host.cleanups; + expect(teardown?.()).toBeUndefined(); + expect(host.handle).toHaveBeenCalledTimes(1); + expect(pi.restart).not.toHaveBeenCalled(); + }); + + 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/settings-page.test.tsx b/test/settings-page.test.tsx index 055240dd..0ddeb9ec 100644 --- a/test/settings-page.test.tsx +++ b/test/settings-page.test.tsx @@ -275,16 +275,7 @@ describe("settings-page: apply & slots", () => { expect(injected).toBeDefined(); expect(injected.directory).toEqual({ isDirectory: true }); // No scope in this context: the meter falls back to the claimed routes. - expect(injected.meterProviders).toEqual([ - "opencode", - "opencode-go", - "opencode-responses", - "opencode-anthropic", - "opencode-google", - "opencode-go-responses", - "opencode-go-anthropic", - "opencode-go-google", - ]); + expect(injected.meterProviders).toEqual(["opencode", "opencode-go"]); const val = await injected.readUsage(); expect(val).toEqual({ test: 123 }); diff --git a/test/stream-hook.test.ts b/test/stream-hook.test.ts index 5d63c894..9f58ed22 100644 --- a/test/stream-hook.test.ts +++ b/test/stream-hook.test.ts @@ -18,12 +18,7 @@ import { basename, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - GO_GOOGLE_INTERNAL_ROUTE, - projectNameFrom, - recordDebug, - RESPONSES_ROUTE, -} from "./support/src.ts"; +import { projectNameFrom, recordDebug } from "./support/src.ts"; import { collectUnknown, createMockStream } from "./test-helpers.ts"; // The debug sink is observed rather than written: a negative assertion over a @@ -176,138 +171,6 @@ describe("stream-hook · the pass-through guards", () => { }); }); -describe("stream-hook · the redirect forwards the caller's request", () => { - /** - * A model the plane serves on another protocol is handed to the route that - * speaks it. The caller's request goes with it whole: only `provider` changes - * (and, for Go-Google, the history is replayed into Google's shape). - * - * `toEqual` deliberately: it ignores a key whose value is `undefined`, so the - * pin holds whether or not the spread carries one. - */ - const hookFor = async ( - providers: string[], - api: string, - route: string, - stream: (options: unknown) => unknown - ) => { - const { createStreamHook, resolveConfig } = - await import("./support/src.ts"); - const { AsyncLocalStorage } = await import("node:async_hooks"); - return createStreamHook( - { - llm: { adapters: new Map([[route, {}]]), stream }, - logger: { info: () => {} }, - } as never, - resolveConfig({ - modelRoutes: { "probe-model": { api } }, - providers, - usageEnabled: false, - } as never), - new AsyncLocalStorage() - ); - }; - - let warn: { mockRestore: () => void } | undefined; - - beforeEach(() => { - // The probe id is in no catalog, so the hook warns once that the picker - // would not offer it. That line is not what these cases are about. - warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - warn?.mockRestore(); - }); - - it("hands the whole request to the protocol route with only provider replaced", async () => { - const stream = vi.fn((_options: unknown) => "redirected"); - const hook = await hookFor( - ["opencode"], - "openai-responses", - RESPONSES_ROUTE, - stream - ); - const options = { - messages: [], - model: "probe-model", - provider: "opencode", - sessionId: "s", - }; - const next = vi.fn(() => "next-result"); - - expect(hook(options, next)).toBe("redirected"); - expect(next).not.toHaveBeenCalled(); - expect(stream).toHaveBeenCalledTimes(1); - expect(stream.mock.calls[0]?.[0]).toEqual({ - ...options, - provider: RESPONSES_ROUTE, - }); - }); - - it("hands a Go-Google request over with its history in Google's shape", async () => { - const sentinel = { notIterable: true }; - const stream = vi.fn((_options: unknown) => sentinel); - const hook = await hookFor( - ["opencode-go"], - "google-generative-ai", - GO_GOOGLE_INTERNAL_ROUTE, - stream - ); - // The user message is the control: only an assistant turn the Go plane's - // Google adapter answered carries a replay identity to translate. - const user = { content: "hi", role: "user" }; - const replayState = { - response: { - api: "google-generative-ai", - kind: "pi-ai", - provider: "opencode-go", - }, - signature: "sig", - }; - const assistant = { - content: "hello", - role: "assistant", - source: { provider: "opencode-go", replayState }, - }; - const options = { - messages: [user, assistant], - model: "probe-model", - provider: "opencode-go", - sessionId: "s", - }; - const next = vi.fn(() => "next-result"); - - expect(hook(options, next)).toBe(sentinel); - expect(next).not.toHaveBeenCalled(); - expect(stream).toHaveBeenCalledTimes(1); - // A literal expectation, not one re-derived through the function under - // test: forwarding the history raw must fail here. - expect(stream.mock.calls[0]?.[0]).toEqual({ - ...options, - messages: [ - user, - { - content: "hello", - role: "assistant", - source: { - provider: GO_GOOGLE_INTERNAL_ROUTE, - replayState: { - response: { - api: "google-generative-ai", - kind: "pi-ai", - provider: GO_GOOGLE_INTERNAL_ROUTE, - }, - signature: "sig", - }, - }, - }, - ], - provider: GO_GOOGLE_INTERNAL_ROUTE, - }); - }); -}); - describe("stream-hook · the debug gate", () => { /** * `debug` is the switch and `debugFile` is only where the switch writes. The diff --git a/test/support/host.ts b/test/support/host.ts index 77222897..e668f016 100644 --- a/test/support/host.ts +++ b/test/support/host.ts @@ -27,15 +27,17 @@ * namespaces — the harness's own pattern for a composition test * (`llm/llm-pi-ai/tests/loader-composition.spec.ts:62-79`). A builtin row is * named `cordis:` (the loader's `vendor/loader/src/config/tree.ts:112-115`), - * and the plugin finds pi-ai's entry by `options.name === - * "@deepseek-ai/dsh-llm-pi-ai"` (`piAiEntry` in `src/responses-provider.ts`), - * so a builtin-named row would silently drop today's hidden-route mount and the - * Google bridge from everything this harness characterizes. + * and 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 silently leave the + * route claim without pi-ai's module and hand the planes back from everything + * this harness characterizes. * * **Tier B fixes the apply order.** The loader starts rows in parallel, and the * stub loader resolves every import at once, so which of pi-ai and the plugin * applies first would otherwise be an accident of row order. It matters: the - * plugin's adapter decoration wraps only what is registered when it applies. + * route claim restarts pi-ai once when pi-ai applied first, and not at all + * when the plugin did. * The default is `plugin-first`, the order measured on the published binary * ({@link ApplyOrder}); `pi-ai-first` is the other one. The held row is * released when the first row's `apply` returns OR its fiber settles in any @@ -47,8 +49,8 @@ * snapshot ({@link fakeLaunchEnvironment}) on the root, which is where pi-ai * looks first (`launchEnvironmentOf` reads `ctx.get("launchEnvironment")` * before it falls back to `process.env`). The plugin itself still reads - * `process.env.OPENCODE_API_KEY`/`OPENCODE_GO_API_KEY` for its keyless listing - * filter (`hasPlaneCredential` in `src/lifecycle.ts`), so tier B blanks either + * `process.env.OPENCODE_API_KEY`/`OPENCODE_GO_API_KEY` in its fetch patch and + * its quota discovery (`src/fetch-patch.ts`, `src/go-discovery.ts`), so tier B blanks either * one a contributor's shell exports for the life of the boot and restores it * on dispose: the suite measures the keyless deployment whatever the shell * holds. @@ -77,7 +79,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Context, type Fiber } from "@deepseek-ai/cordis"; -import type { Entry, ModuleLoaderV2 } from "@deepseek-ai/cordis-plugin-loader"; +import { + type Entry, + interpolate, + type ModuleLoaderV2, +} from "@deepseek-ai/cordis-plugin-loader"; import { boot, initProfile, @@ -85,13 +91,42 @@ import { readProfilePatches, } from "@deepseek-ai/dsh-app-boot"; import { ConfigEditor } from "@deepseek-ai/dsh-config-editor"; -import { LlmRuntime } from "@deepseek-ai/dsh-llm"; +import { + assertUsableApiKey, + LlmError, + LlmRuntime, + resolveRetryPolicy, +} from "@deepseek-ai/dsh-llm"; import * as LlmPiAi from "@deepseek-ai/dsh-llm-pi-ai"; import Settings from "@deepseek-ai/dsh-settings"; +import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy"; +import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; +import { lazyStream } from "@earendil-works/pi-ai/api/lazy"; +import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; +import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; +import { + clampThinkingLevel, + createProvider, +} from "@earendil-works/pi-ai/models"; +import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; +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 { apply, Config, inject, name } from "./src.ts"; - -/** The package name the plugin looks pi-ai up by (`PI_AI_PACKAGE` in `src/responses-provider.ts`). */ +import { + apply, + Config, + type GoogleLevelPieces, + type GoogleLevelSource, + type HostLibraries, + type HostModuleSource, + inject, + name, + type PiAiModule, + replaceHostModuleSource, +} from "./src.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. */ @@ -531,56 +566,45 @@ const tags = ( ): Readonly> => ({ callConfig, info, wire }); const B = "BASELINE"; -const D = "KNOWN_DEFECT"; -const I = "INTENDED_CHANGE"; /** - * TODAY's plugin (hidden routes, listing patch, adapter decoration and all) on - * pi 1.1.0 with {@link CHARACTERIZED_PROFILE} and a key stored for each plane, - * measured in tier B with the published host's apply order (`plugin-first`, - * see {@link ApplyOrder}) and shared with the binary e2e so the two cannot - * drift. Each facet carries its own tag, and the characterization generates - * one test per row and facet titled with it: P-R1 flips the `KNOWN_DEFECT` and - * `INTENDED_CHANGE` facets its PR body names, and no `BASELINE` facet moves — - * except that P-R2's measured family flip, if a `modern` cell fails, may move - * the endpoint-family part of a `BASELINE` wire facet, re-tagging each one it - * moves and naming it in its PR body. + * What the plugin does with each characterized model on pi 1.1.0, measured in + * tier B with {@link CHARACTERIZED_PROFILE} and a key stored for each plane, + * and shared with the binary e2e so the two cannot drift. Each facet carries + * its own tag, and the characterization generates one test per row and facet + * titled with it. P-T1b recorded these against the hidden-route workaround; + * P-R1 (the plugin owns the plane routes) flipped the fourteen + * `KNOWN_DEFECT`/`INTENDED_CHANGE` facets its PR body names to `BASELINE`, so + * every facet here is one no later phase may move — except that P-R2's + * measured family flip, if a `modern` cell fails, may move the + * endpoint-family part of a wire facet, re-tagging each one it moves and + * naming it in its PR body. * - * `context` and `efforts` are copied from the generated catalog + * `context` and `efforts` follow D17 and D1: the user's row first (Zen + * gpt-5.3-codex-spark's 400000), then the bundled catalog * (`src/catalog-data.ts`), which the nightly refresh rewrites. A fixture-sanity - * case in `test/host-characterization.test.ts` compares each row's context AND - * effort ids to the bundled catalog's derivation, so a refresh that moves a - * fixture model fails there by name — update the row here. A phase that - * moves a non-`BASELINE` `info` facet off the catalog on purpose (D17's user - * row, D1's toggle efforts) adds that row's key to the case's - * `INFO_NOT_FROM_CATALOG` set in the same diff. The binary e2e - * reads the same table, but its job runs on a PR only when this file is in - * its path scope (`.github/workflows/ci.yml`, `e2e-host`), which it is. + * case in `test/host-characterization.test.ts` derives each row's context and + * effort ids the same way, so a refresh that moves a fixture model fails there + * by name — update the row here. The binary e2e reads the same table, and its + * job runs on a PR only when this file is in its path scope + * (`.github/workflows/ci.yml`, `e2e-host`), which it is. */ export const S0_TODAY = { - /** `listModels` before any turn: the plugin sees no key, so it hides paid models. */ - keylessListing: { opencode: [], "opencode-go": [] }, - /** `listModels` once a turn captured a key: pi-resolved ids only. */ - keyedListing: { - opencode: [ - "deepseek-v4-pro", - "gpt-5.4", - "claude-haiku-5-5", - "gemini-3-flash", - ], - "opencode-go": [ - "deepseek-v4-pro", - "muse-spark-1.2-contributor", - "minimax-m3", - ], - }, + /** + * `listModels` before any turn, with the keys stored: every declared id + * (D21). The keyless boot is `test/host-planes.test.ts`'s declared-listing + * case. + */ + listingBeforeTurn: CHARACTERIZED_MODELS, + /** `listModels` once a turn ran: the same declared ids. */ + listingAfterTurn: CHARACTERIZED_MODELS, rows: { "opencode/deepseek-v4-pro": { callConfig: "ok", info: { context: 1_000_000, efforts: ["high", "max"] }, finish: "AUTH", - sent: [`${ZEN}/zen/v1/chat/completions`], - tags: tags(B, B, I), + sent: [`${ZEN}/inference/openai/v1/chat/completions`], + tags: tags(B, B, B), }, "opencode/gpt-5.4": { callConfig: "ok", @@ -611,27 +635,28 @@ export const S0_TODAY = { ], tags: tags(B, B, B), }, - // The user's row (name "My Spark", contextWindow 400000) is ignored. + // The user's row (name "My Spark", contextWindow 400000) wins (D17). "opencode/gpt-5.3-codex-spark": { - callConfig: "INVALID_CONFIG", - info: { context: 128_000, efforts: ["low", "medium", "high", "xhigh"] }, + callConfig: "ok", + info: { context: 400_000, efforts: ["low", "medium", "high", "xhigh"] }, finish: "AUTH", sent: [`${ZEN}/inference/openai/v1/responses`], - tags: tags(D, D, B), + tags: tags(B, B, B), }, + // Neither catalog describes it: served as completions on the defaults (D15, D16). "opencode/not-in-any-catalog": { - callConfig: "INVALID_CONFIG", - finish: "INVALID_CONFIG", - info: { error: "INVALID_CONFIG" }, - sent: [], - tags: tags(D, D, D), + callConfig: "ok", + finish: "AUTH", + info: { context: 262_144, efforts: [] }, + sent: [`${ZEN}/inference/openai/v1/chat/completions`], + tags: tags(B, B, B), }, "opencode-go/deepseek-v4-pro": { callConfig: "ok", info: { context: 1_000_000, efforts: ["high", "max"] }, finish: "AUTH", - sent: [`${ZEN}/zen/go/v1/chat/completions`], - tags: tags(B, B, I), + sent: [`${ZEN}/inference/go/openai/v1/chat/completions`], + tags: tags(B, B, B), }, "opencode-go/muse-spark-1.2-contributor": { callConfig: "ok", @@ -643,53 +668,48 @@ export const S0_TODAY = { sent: [`${ZEN}/inference/go/openai/v1/responses`], tags: tags(B, B, B), }, - // A toggle-only model offered Minimal/Xhigh/Max (D1). + // A toggle-only model on Messages: Off / On (D1). "opencode-go/minimax-m3": { callConfig: "ok", - info: { - context: 1_000_000, - efforts: ["minimal", "low", "medium", "high", "xhigh", "max"], - }, + info: { context: 1_000_000, efforts: ["off", "high"] }, finish: "AUTH", sent: [`${ZEN}/inference/go/anthropic/v1/messages?beta=true`], - tags: tags(D, B, B), + tags: tags(B, B, B), }, "opencode-go/gemini-4-argon": { - callConfig: "INVALID_CONFIG", + callConfig: "ok", info: { context: 1_048_576, efforts: ["low", "medium", "high"] }, finish: "PI_AI_ERROR", sent: [ `${ZEN}/inference/go/google/v1beta/models/gemini-4-argon:streamGenerateContent?alt=sse`, ], - tags: tags(B, D, B), + tags: tags(B, B, B), }, "opencode-go/minimax-m3.1-flash": { - callConfig: "INVALID_CONFIG", + callConfig: "ok", info: { context: 1_000_000, efforts: ["low", "medium", "high", "xhigh", "max"], }, - finish: "INVALID_CONFIG", - sent: [], - tags: tags(B, D, D), + finish: "AUTH", + sent: [`${ZEN}/inference/go/openai/v1/chat/completions`], + tags: tags(B, B, B), }, "opencode-go/not-in-any-catalog": { - callConfig: "INVALID_CONFIG", - finish: "INVALID_CONFIG", - info: { error: "INVALID_CONFIG" }, - sent: [], - tags: tags(D, D, D), + callConfig: "ok", + finish: "AUTH", + info: { context: 262_144, efforts: [] }, + sent: [`${ZEN}/inference/go/openai/v1/chat/completions`], + tags: tags(B, B, B), }, } satisfies Record, /** - * What appending a new id to `opencode` answers while the plane stores a gap - * id: the strict check names the STORED gap id, whatever is being saved. + * What appending a new id to `opencode` answers on pi-ai ALONE (the plugin + * disposed) while the plane stores a gap id: pi-ai's strict check names the + * STORED gap id, whatever is being saved. With the plugin, any id saves (D15). */ saveBlockedByStoredGap: /provider "opencode" model "gpt-5\.3-codex-spark" needs an api/u, - /** What saving a catalog-unknown id answers once the plane stores no gap. */ - saveRejectedUnknown: - /provider "opencode" model "not-a-real-model" needs an api/u, } as const; /** The id the save rows try to add: unknown to every catalog, and not declared. */ @@ -703,42 +723,24 @@ export const SAVE_CANDIDATE = "not-a-real-model"; export const ZEN_UNDESCRIBED = ["gpt-5.3-codex-spark", UNKNOWN_ID] as const; /** - * TODAY's rows for an apply order. `S0_TODAY.rows` is the `plugin-first` - * table; with `pi-ai-first` the adapter decoration wraps pi-ai's plane adapter - * and exactly one facet moves — Go gemini-4-argon validates — - * measured in `test/host-characterization.test.ts`, which generates one test - * per row and facet for each order, titled with that order's tags. The moved - * facet is `BASELINE` in this table: its `"ok"` is already what P-R1 keeps, so - * there is nothing for a phase to flip in this order. The binary e2e picks the - * table from the order its probe INFERRED — whether the decoration wrapped - * pi-ai's plane adapter, which is what the rows depend on — because on a real - * host the order is a race (F17) the suite must not pin. + * The rows for an apply order. With the plugin owning the planes, the apply + * order no longer moves any facet, so both orders answer `S0_TODAY.rows`. Only + * the in-process characterization calls it, once per order it boots (the binary + * e2e reads `S0_TODAY.rows` directly); an order this function does not know is + * refused rather than read as one it does. * - * @param order - the apply order, as inferred from the decoration. + * @param order - the apply order. * @returns the rows, keyed `/`. */ export const todayRowsFor = ( order: ApplyOrder ): Readonly> => { - if (order === "plugin-first") { - return S0_TODAY.rows; - } // Exhaustive on purpose: a probe JSON with a missing or renamed field reads - // `undefined` here, and must not fall through to the pi-ai-first table. - if (order !== "pi-ai-first") { + // `undefined` here. + if (order !== "plugin-first" && order !== "pi-ai-first") { throw new Error(`todayRowsFor: unknown apply order ${String(order)}`); } - return { - ...S0_TODAY.rows, - "opencode-go/gemini-4-argon": { - ...S0_TODAY.rows["opencode-go/gemini-4-argon"], - callConfig: "ok", - tags: { - ...S0_TODAY.rows["opencode-go/gemini-4-argon"].tags, - callConfig: B, - }, - }, - }; + return S0_TODAY.rows; }; /** @@ -751,20 +753,6 @@ export const APPLY_ORDERS = Object.keys({ "plugin-first": true, } satisfies Record) as readonly ApplyOrder[]; -/** - * What appending a new id to `opencode` answers while the plane stores ANY of - * {@link ZEN_UNDESCRIBED}, whichever one the installed pi cannot describe - * first. The pinned pi always names `gpt-5.3-codex-spark` - * (`S0_TODAY.saveBlockedByStoredGap`); a floating pi that learned it names the - * next one, so the floating cell asserts this instead. - */ -export const SAVE_BLOCKED_BY_ANY_UNDESCRIBED = new RegExp( - `provider "opencode" model "(?:${ZEN_UNDESCRIBED.map((id) => - id.replaceAll(".", String.raw`\.`) - ).join("|")})" needs an api`, - "u" -); - const PI_DATA = new URL( "../../node_modules/@earendil-works/pi-ai/dist/providers/data/", import.meta.url @@ -868,9 +856,9 @@ export const waterfallSpy = ( }; /** - * Poll the registry until every route is present: the plugin mounts its - * hidden routes asynchronously (`mountInternalRoutes` in - * `src/lifecycle.ts`). + * Poll the registry until every route is present: a route registers after + * its owner's config resolves, which is asynchronous. For the two planes, + * {@link waitForPlaneOwner} also waits for the plugin to be their owner. * * @param llm - the `llm` service. * @param ids - the routes to wait for. @@ -901,6 +889,187 @@ export const waitForRoutes = async ( } }; +/** + * Poll the registry until each plane is served by an adapter of class + * `owner`: the plugin registers its plane adapter once its mount settles. + * + * @param llm - the `llm` service. + * @param planes - the routes to wait for. + * @param owner - the adapter's class name. + * @param deadlineMs - how long to wait before failing by name. + */ +export const waitForPlaneOwner = async ( + llm: HostLlm, + planes: readonly string[] = PLANES, + owner = "PlaneAdapter", + deadlineMs = 10_000 +): Promise => { + const until = Date.now() + deadlineMs; + for (;;) { + const registry = adaptersOf(llm); + const wrong = planes.filter( + (plane) => registry.get(plane)?.adapter.constructor.name !== owner + ); + if (wrong.length === 0) { + return; + } + if (Date.now() > until) { + throw new Error( + `host harness: ${wrong.join(", ")} never came to ${owner} (${wrong + .map( + (plane) => + registry.get(plane)?.adapter.constructor.name ?? "unregistered" + ) + .join(", ")})` + ); + } + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } +}; + +/** The adapter class serving a route, or `undefined`. */ +export const ownerOf = (llm: HostLlm, route: string): string | undefined => + adaptersOf(llm).get(route)?.adapter.constructor.name; + +/** cordis `FiberState.UNLOADING`. */ +const UNLOADING_STATES: ReadonlySet = new Set([5]); + +/** + * Count pi-ai's restarts from now on: every UNLOADING transition of a pi-ai + * fiber that is not being disposed (a dispose clears `uid` first). + * + * @param root - the root context. + * @returns the live count, and the disposer. + */ +export const piAiRestarts = ( + root: Context +): { readonly count: () => number; readonly dispose: () => void } => { + let count = 0; + const off = root.on("internal/status", (fiber: Fiber) => { + if ( + fiber.runtime?.name === "llm-pi-ai" && + UNLOADING_STATES.has(fiber.state) && + fiber.uid !== null + ) { + count += 1; + } + }); + return { + count: () => count, + dispose: () => { + off(); + }, + }; +}; + +/** + * 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], + }; +}; + +/** + * pi's Google level pieces from this repository's install, the Google ones + * imported on first use as the plugin's default source imports them. + */ +const googleLevelsForTests = (): GoogleLevelSource => ({ + lazyStream: lazyStream as unknown as GoogleLevelSource["lazyStream"], + load: async () => { + const [google, shared, simple] = await Promise.all([ + import("@earendil-works/pi-ai/api/google-generative-ai"), + import("@earendil-works/pi-ai/api/google-shared"), + import("@earendil-works/pi-ai/api/simple-options"), + ]); + return { + buildBaseOptions: simple.buildBaseOptions, + clampThinkingLevel, + resolveGoogleThinkingLevel: shared.resolveGoogleThinkingLevel, + stream: google.stream, + toGoogleSdkThinkingLevel: shared.toGoogleSdkThinkingLevel, + toGoogleThinkingLevel: shared.toGoogleThinkingLevel, + usesGoogleThinkingLevel: shared.usesGoogleThinkingLevel, + } as unknown as GoogleLevelPieces; + }, +}); + +/** + * 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 + * the seam where there is no loader (tier A). + * + * @returns the source. + */ +export const hostModulesForTests = (): HostModuleSource => { + const libraries: HostLibraries = { + apis: { + "anthropic-messages": anthropicMessagesApi, + "google-generative-ai": googleGenerativeAIApi, + "openai-completions": openAICompletionsApi, + "openai-responses": openAIResponsesApi, + }, + assertUsableApiKey, + builtinModels: (route) => + getBuiltinModels(route as never) as unknown as ReturnType< + HostLibraries["builtinModels"] + >, + createProvider: + createProvider as unknown as HostLibraries["createProvider"], + googleLevels: googleLevelsForTests(), + interpolate: interpolate as HostLibraries["interpolate"], + LlmError: LlmError as unknown as HostLibraries["LlmError"], + opencodeAuth: opencodeProvider().auth, + opencodeGoAuth: opencodeGoProvider().auth, + resolveRetryPolicy: + resolveRetryPolicy as unknown as HostLibraries["resolveRetryPolicy"], + version: "1.1.0", + withOpenCodeSessionHeader: + withOpenCodeSessionHeader as unknown as HostLibraries["withOpenCodeSessionHeader"], + }; + return { + libraries: () => Promise.resolve(libraries), + piAi: () => Promise.resolve(LlmPiAi as unknown as PiAiModule), + }; +}; + /** One drained `llm.stream` call: the chunk types and the final finish. */ export interface DrainedStream { readonly types: string[]; @@ -908,6 +1077,8 @@ export interface DrainedStream { | { readonly kind: string | undefined; readonly code: string | undefined } | undefined; readonly error: string | undefined; + /** The finish's failure message, when it carried one. */ + readonly failureMessage: string | undefined; } /** @@ -921,21 +1092,27 @@ export interface DrainedStream { export const drainStream = async ( llm: HostLlm, provider: string, - model: string + model: string, + extra: Readonly> = {} ): Promise => { const types: string[] = []; let finish: DrainedStream["finish"]; let failed: string | undefined; + let failureMessage: string | undefined; try { for await (const chunk of llm.stream({ messages: [{ content: [{ text: "hi", type: "text" }], role: "user" }], model, provider, signal: AbortSignal.timeout(10_000), + ...extra, } as never)) { const typed = chunk as { type: string; - reason?: { kind?: string; failure?: { code?: string } }; + reason?: { + kind?: string; + failure?: { code?: string; message?: string }; + }; }; types.push(typed.type); if (typed.type === "finish") { @@ -943,13 +1120,14 @@ export const drainStream = async ( code: typed.reason?.failure?.code, kind: typed.reason?.kind, }; + failureMessage = typed.reason?.failure?.message; } } } catch (error) { const failure = error as { code?: unknown; message?: unknown }; failed = `${typeof failure.code === "string" ? `${failure.code} ` : ""}${String(failure.message ?? error)}`; } - return { error: failed, finish, types }; + return { error: failed, failureMessage, finish, types }; }; // --------------------------------------------------------------------------- @@ -965,18 +1143,23 @@ export interface TierAOptions { /** * Mount this plugin too, with this row config. Stub `fetch` first (its fetch * patch captures `globalThis.fetch` at effect time). Tier A has no loader, - * so the plugin cannot find pi-ai's entry: it applies and patches fetch, but - * mounts NO hidden route and no Google bridge: the bridge's `PiAiAdapter` - * comes from the loader's pi-ai entry (`loadPiAiAdapterHost`), which tier A - * cannot answer. `test/host-harness.test.ts` pins both — the stand-down line - * and empty hidden routes, and `loadPiAiAdapterHost` answering `undefined`. - * Characterize the real plugin in tier B. + * so the plugin's default source cannot find pi-ai's module: the harness + * swaps in {@link hostModulesForTests} (the `hostModules` option, `null` to + * keep the default), which answers the statically imported modules, so the + * route claim serves the planes here as it does on the real host. + * Characterize the plugin as it ships in tier B. */ readonly plugin?: Record; readonly credentials?: FakeCredentials; readonly launchEnvironment?: FakeLaunchEnvironment; /** Runs on the root before pi-ai mounts (a listener to register first). */ readonly before?: (ctx: Context) => void; + /** + * Where the plugin finds the host's modules. Tier A has no loader, so a + * plugin mounted here reads them from {@link hostModulesForTests} by + * default; `null` leaves the plugin's own default source in place. + */ + readonly hostModules?: HostModuleSource | null; } /** A mounted tier A. */ @@ -1001,6 +1184,12 @@ export const mountTierA = async (options: TierAOptions): Promise => { const credentials = options.credentials ?? fakeCredentials(); const launchEnvironment = options.launchEnvironment ?? fakeLaunchEnvironment(); + const restoreSource = + options.plugin === undefined || options.hostModules === null + ? (): void => { + // The default source stands: nothing was replaced. + } + : replaceHostModuleSource(options.hostModules ?? hostModulesForTests()); try { ctx.provide("credentials", credentials.service); ctx.provide("launchEnvironment", launchEnvironment.snapshot); @@ -1021,7 +1210,11 @@ export const mountTierA = async (options: TierAOptions): Promise => { credentials, ctx, dispose: async () => { - await ctx.fiber.dispose(); + try { + await ctx.fiber.dispose(); + } finally { + restoreSource(); + } }, launchEnvironment, llm: ctx.llm, @@ -1029,7 +1222,11 @@ export const mountTierA = async (options: TierAOptions): Promise => { pluginFiber, }; } catch (error) { - await ctx.fiber.dispose(); + try { + await ctx.fiber.dispose(); + } finally { + restoreSource(); + } throw error; } }; @@ -1208,8 +1405,8 @@ export const AMBIENT_KEYS = [ ] as const; /** - * Blank the keys the plugin reads directly (`hasPlaneCredential` in - * `src/lifecycle.ts`) for the life of one boot, so a contributor's exported + * Blank the keys the plugin reads directly (`src/fetch-patch.ts`, + * `src/go-discovery.ts`) for the life of one boot, so a contributor's exported * key cannot turn the keyless rows into keyed ones. An empty value is what the * plugin reads as "no key". Only a key that IS set is touched, and the * returned restore puts back exactly what was there. @@ -1445,3 +1642,48 @@ export const providerWithApiKey = async ( } return undefined; }; + +/** What the Models page shows for one plane: whether it is configured, under which key. */ +export interface ModelsPageRow { + readonly configured: boolean; + readonly apiKeyEnv: string | undefined; +} + +/** + * The Models page's join, restated (`client/ui-settings-models/src/client/store.ts` + * `joinProviderDirectory` and `read`, `:45-77`, `:174-182`, `:250-269`): each + * plane's directory entry, looked up in `settings.describe({ redactSecrets: + * true })` at its `settingsPath`. The client package is not installed here, + * so the join is rebuilt from the real parts tier B holds. + * + * @param tier - the booted host. + * @returns per plane, whether the page reads it configured and its `apiKeyEnv`. + */ +export const modelsPageRows = (tier: TierB): Record => { + const views = tier.ctx.settings.describe({ redactSecrets: true }); + const rows: Record = {}; + for (const entry of tier.llm.listConfigurableProviders()) { + if (!(PLANES as readonly string[]).includes(entry.provider)) { + continue; + } + const view = views.find((namespace) => namespace.ns === entry.settingsNs); + let profile: unknown = view?.value; + for (const key of entry.settingsPath) { + profile = + typeof profile === "object" && profile !== null + ? Reflect.get(profile, key) + : undefined; + } + const ref: unknown = + typeof profile === "object" && profile !== null + ? Reflect.get(profile, "apiKeyEnv") + : undefined; + rows[entry.provider] = { + apiKeyEnv: typeof ref === "string" && ref.length > 0 ? ref : undefined, + configured: + view !== undefined && + (entry.settingsPath.length === 0 || profile !== undefined), + }; + } + return rows; +}; diff --git a/test/support/keyless-matrix.ts b/test/support/keyless-matrix.ts index d83d6235..1bd25a67 100644 --- a/test/support/keyless-matrix.ts +++ b/test/support/keyless-matrix.ts @@ -21,7 +21,7 @@ import { type CatalogModelSpec, LEGACY_PLANE_API, OPENCODE_FAMILY_API, - protocolFor, + protocolForSdk, } from "./src.ts"; export type MatrixPlane = "opencode" | "opencode-go"; @@ -56,39 +56,26 @@ export const cellKey = ( ): string => `${cell.plane} ${cell.protocol} ${cell.family}`; /** - * The cells TODAY's plugin sends turns to with the default endpoint (`auto`, - * an alias of `modern`), on both planes: completions on pi 1.1.0's LEGACY - * bases (`/zen(/go)/v1/chat/completions`, pi-ai's own plane route) and the - * three hidden routes' MODERN paths (Responses, Messages, Google). A path-gone - * verdict on one of these breaks a shipped model in the default - * configuration, so the live matrix asserts them. `test/keyless-matrix.test.ts` - * derives the same set from the URLs `S0_TODAY` measured (with the default - * endpoint), so the two cannot drift. + * The cells the plugin sends turns to with the default endpoint (`auto`, an + * alias of `modern`), on both planes: since P-R1 every protocol, completions + * included, on the MODERN family (`/inference(/go)/…`). A path-gone verdict on + * one of these breaks a shipped model in the default configuration, so the + * live matrix asserts them. `test/keyless-matrix.test.ts` derives the same set + * from the URLs `S0_TODAY` measured (with the default endpoint), so the two + * cannot drift. * - * Not covered: a user who sets `endpoint: legacy` dispatches Responses and - * Messages on both planes, and Zen's Google, to the LEGACY cells, which the - * matrix records for P-R2 and does not assert; P-R2 decides whether that - * configuration is guarded. Go's Google is the exception: it stays on - * `/inference/go/google/v1beta` under every family (`GO_GOOGLE_BASE_URL` in - * `src/google-bridge.ts`), which is the modern cell this set already holds. + * Not covered: a user who sets `endpoint: legacy` dispatches to the LEGACY + * cells, which the matrix records for P-R2 and does not assert; P-R2 decides + * whether that configuration is guarded. Go's Google is the exception: it + * stays on `/inference/go/google/v1beta` under every family + * (`GO_GOOGLE_BASE_URL` in `src/utils/protocols.ts`), which is the modern cell + * this set already holds. */ export const TODAY_DISPATCH: ReadonlySet = new Set( - (["opencode", "opencode-go"] as const).flatMap((plane) => [ - cellKey({ family: "legacy", plane, protocol: "openai-completions" }), - cellKey({ family: "modern", plane, protocol: "openai-responses" }), - cellKey({ family: "modern", plane, protocol: "anthropic-messages" }), - cellKey({ family: "modern", plane, protocol: "google-generative-ai" }), - ]) -); - -/** - * The cells P-R1 moves traffic to that nothing dispatches to yet: completions - * on the modern family (`/inference(/go)/openai/v1`). The live matrix asserts - * them too, as P-R1's forward guard, and labels them so in its failure. - */ -export const NEXT_DISPATCH: ReadonlySet = new Set( - (["opencode", "opencode-go"] as const).map((plane) => - cellKey({ family: "modern", plane, protocol: "openai-completions" }) + (["opencode", "opencode-go"] as const).flatMap((plane) => + MATRIX_PROTOCOLS.map((protocol) => + cellKey({ family: "modern", plane, protocol }) + ) ) ); @@ -208,7 +195,7 @@ const representative = ( ): string | undefined => specs.find( (spec) => - protocolFor(spec) === protocol && + protocolForSdk(spec.provider_npm) === protocol && !isFreeSpec(spec) && !isDeprecated(spec.id) )?.id; @@ -266,6 +253,24 @@ export const keylessMatrix = (catalogs: { export const isTransientProbeAnswer = (status: number | undefined): boolean => status === undefined || status === 429 || status >= 500; +/** + * Whether a live turn's failure message says the gateway withdrew the model. + * + * The same two answers {@link classifyKeylessProbe} reads as `model-gone` — a + * `410`, or a `400` saying "Model is unavailable" — as they reach a turn's + * failure message rather than a raw response. It is a PRE-FILTER, not a + * verdict: the gateway answers "Model is unavailable" for an id sent where it + * is not served too (`docs/upstream-findings.md` §3.8), so the live adapter + * blocks move past such a model only once a raw probe of the same id + * (`modelGone` in `test/support/live-probe.ts`) confirms the vendor withdrew + * it, and fail the turn otherwise. + * + * @param message - the turn's failure message. + * @returns whether the message names a withdrawn model. + */ +export const isModelGoneMessage = (message: string): boolean => + /\b410\b|model is unavailable/iu.test(message); + /** * Read one keyless answer. * diff --git a/test/support/live-probe.ts b/test/support/live-probe.ts new file mode 100644 index 00000000..4717fb93 --- /dev/null +++ b/test/support/live-probe.ts @@ -0,0 +1,206 @@ +/** + * Sending one raw request to the gateway, the way the live suites do. + * + * The live protocol-routing suite writes its probes by hand: a path, an auth + * convention and a body per protocol, with the headers the plugin injects. + * That is what makes a probe an exact oracle for the vendor, and it is also + * what the live adapter blocks need to CONFIRM that the vendor withdrew a + * model before they skip it: a turn through the plane adapter that answers + * "Model is unavailable" says nothing on its own, because the gateway gives + * the same answer for an id the plugin sent to the wrong place + * (`docs/upstream-findings.md` §3.8). So the tables and the send live here, + * shared by `test/e2e/protocol-routing.e2e.ts` and + * `test/e2e/dispatch-levels.e2e.ts`. + * + * Network only when {@link sendProbe} is called; nothing here runs on import. + * + * @module test/support/live-probe + */ +import { OPENCODE_PATCH_USER_AGENT } from "./src.ts"; + +/** + * The path each protocol uses against an OVERRIDDEN base, which is read as a + * legacy-family base (`…/zen/v1`): one base for every protocol. + */ +export const PATH_FOR: Readonly> = { + "anthropic-messages": "/messages", + // The Gemini path carries the MODEL in the URL, not the body, so the probe + // substitutes the `{model}` placeholder the same way the wire does. + "google-generative-ai": "/models/{model}:streamGenerateContent?alt=sse", + "openai-completions": "/chat/completions", + "openai-responses": "/responses", +}; + +/** + * The path each protocol appends to the base the plugin's model names: pi's + * Anthropic client appends `/v1/messages` to a base without `/v1`, the others + * append their own path to a base that carries its version. + */ +export const PLUGIN_PATH_FOR: Readonly> = { + "anthropic-messages": "/v1/messages", + "google-generative-ai": "/models/{model}:streamGenerateContent?alt=sse", + "openai-completions": "/chat/completions", + "openai-responses": "/responses", +}; + +/** + * Each endpoint's OWN auth convention. + * + * `/messages` is the Anthropic Messages shape, which reads `x-api-key` and + * ignores `Authorization`. Sending the Bearer form there answers + * `401 Missing API key` — the header is simply not read — so a probe that used + * one convention everywhere would conclude the model was unreachable. + */ +export const AUTH_FOR: Readonly< + Record Record> +> = { + "anthropic-messages": (key) => ({ + "anthropic-version": "2023-06-01", + "x-api-key": key, + }), + "google-generative-ai": (key) => ({ "x-goog-api-key": key }), + "openai-completions": (key) => ({ authorization: `Bearer ${key}` }), + "openai-responses": (key) => ({ authorization: `Bearer ${key}` }), +}; + +/** A body shaped for the protocol, so the gateway judges the MODEL, not the JSON. */ +export const BODY_FOR: Readonly>> = { + "anthropic-messages": { + max_tokens: 1, + messages: [{ content: "hi", role: "user" }], + }, + // An empty `messages` array is itself a 400, which would make every + // completions probe uninformative — the gateway would reject the body before + // it ever looked at the model. + "openai-completions": { + max_tokens: 8, + messages: [{ content: "hi", role: "user" }], + stream: false, + }, + "google-generative-ai": { + // Measured: without `role` the upstream refuses with "Please use a valid + // role: user, model" — the gateway forwards it, which is itself proof the + // path and the x-goog-api-key convention are accepted. + contents: [{ parts: [{ text: "hi" }], role: "user" }], + generationConfig: { maxOutputTokens: 8 }, + }, + "openai-responses": { input: "hi", max_output_tokens: 16 }, +}; + +/** Headers the plugin injects, so a probe looks like the traffic we actually send. */ +export const PLUGIN_HEADERS: Readonly> = { + "content-type": "application/json", + "user-agent": OPENCODE_PATCH_USER_AGENT, + "x-opencode-client": "cli", + "x-opencode-project": "global", + "x-opencode-session": "ses_e2e0000000000abcdefghij", +}; + +export interface Probe { + readonly status: number; + readonly summary: string; +} + +/** + * Send one protocol-shaped request to `url` and read the answer. + * + * The body is the protocol's own (`BODY_FOR`), with the model in the body + * unless the protocol's path carries it; the credential header is the + * protocol's own (`AUTH_FOR`), and no key sends none. + * + * @param input.url - the full URL, path included. + * @param input.protocol - the protocol the body and the auth header follow. + * @param input.model - the model id. + * @param input.key - the credential, or `undefined` for a keyless probe. + * @param input.timeoutMs - the request's own timeout. + * @returns the status and a summary; status `0` when nothing answered. + */ +export const sendProbe = async (input: { + readonly url: string; + readonly protocol: string; + readonly model: string; + readonly key?: string | undefined; + readonly timeoutMs: number; +}): Promise => { + const { key, model, protocol } = input; + const template = PATH_FOR[protocol]; + if (template === undefined) { + throw new Error(`no live path is known for protocol ${protocol}`); + } + // A protocol whose path carries the model does not also want it in the body: + // the Gemini endpoints reject unknown fields rather than ignoring them. + const payload = template.includes("{model}") + ? BODY_FOR[protocol] + : { ...BODY_FOR[protocol], model }; + const auth = + key === undefined || key.length === 0 + ? {} + : (AUTH_FOR[protocol]?.(key) ?? {}); + let response: Response; + try { + response = await fetch(input.url, { + body: JSON.stringify(payload), + headers: { ...PLUGIN_HEADERS, ...auth }, + method: "POST", + signal: AbortSignal.timeout(input.timeoutMs), + }); + } catch (error) { + // Status 0 means "the gateway did not answer" — neither agreement nor + // refusal. Measured while writing this: under rate limiting the gateway + // answers 429 at first and then holds the connection open without a + // response until the client gives up, so a timeout here is the gateway being + // busy rather than this plugin being wrong. Reporting it as a status is what + // lets the case SKIP instead of failing on an answer nobody received. + return { + status: 0, + summary: `no answer: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + const body = await response.text(); + return { + status: response.status, + summary: `${response.status} ${body.slice(0, 200)}`, + }; +}; + +/** + * Whether the gateway answered that it no longer serves the MODEL at all. + * + * `400 Upstream request failed: Model is unavailable` and `410` are the gateway + * speaking about the model rather than the route: the catalog can still list an + * id the upstream has withdrawn (measured 2026-10-26 on `nemotron-3-ultra-free`, + * answered identically on two runs a minute apart). Such a candidate is skipped + * like any other the gateway left nothing to assert about, never asserted on. + */ +export const modelGone = (result: Probe): boolean => + result.status === 410 || + (result.status === 400 && result.summary.includes("Model is unavailable")); + +/** + * Whether the gateway's answer makes routing UNOBSERVABLE, so the case skips. + * + * Two shapes qualify, and both are the gateway speaking about the ACCOUNT + * rather than about the route: + * + * - `403 Model access is disabled` — the account has no paid access, so every + * endpoint answers alike and nothing about routing can be seen. + * - `429` — the account is rate limited. Measured while writing this: the + * gateway answers `429 Upstream request failed` on the first calls, and then + * HOLDS THE CONNECTION open without a response for a minute, which is what + * turned a routing case into a timeout rather than a skip. Every endpoint + * would answer alike here too, so the same reasoning applies. + * + * A skip is the honest answer in both: it says the routing was not verified, + * which is not the same as saying it was verified and passed. + */ +export const accessDisabled = (result: Probe): boolean => + result.status === 402 || + result.summary.includes("Insufficient account funds") || + (result.status === 403 && + result.summary.includes("Model access is disabled")) || + result.status === 429 || + // No answer at all is the third shape of "cannot tell": the gateway is busy, + // so nothing about the route was observed either way. + result.status === 0; diff --git a/test/support/live-walk.ts b/test/support/live-walk.ts new file mode 100644 index 00000000..407e28ca --- /dev/null +++ b/test/support/live-walk.ts @@ -0,0 +1,107 @@ +/** + * Walking a protocol's candidates until one reaches the gateway. + * + * The live adapter blocks (`test/e2e/protocol-routing.e2e.ts`, + * `test/e2e/dispatch-levels.e2e.ts`) claim one turn per protocol per plane. + * The vendor withdraws models and an account may not be entitled to some, so + * the first model of a protocol is not always one that can answer. Dropping + * such a protocol and passing on the others reports a protocol as tested when + * it was not, which is how an earlier revision of both blocks read. So each + * protocol walks its candidates in catalog order, the way the raw probes' + * `pickModel` does, and stops at the first turn that says something about the + * wire; a protocol whose every candidate was refused on entitlement or + * withdrawn is reported UNREACHED by name, and the case skips with that name + * rather than passing. + * + * Pure: the turn is a parameter, so the walk is unit-tested offline in + * `test/live-walk.test.ts`. + * + * @module test/support/live-walk + */ + +/** What one turn said, as the walk reads it. */ +export type TurnVerdict = + /** A completed turn: the protocol is reached and passed. */ + | { readonly kind: "ok" } + /** An answer about the account (no paid access, no funds, rate limited). */ + | { readonly kind: "entitlement" } + /** The vendor withdrew the model, CONFIRMED by a raw probe of the same id. */ + | { readonly kind: "gone" } + /** Anything else: the protocol is reached and failed. */ + | { readonly kind: "refused"; readonly detail: string }; + +/** The walk's record: every protocol is reached, refused, or named unreached. */ +export interface ProtocolWalk { + /** Each reached protocol and the candidate that reached it. */ + readonly reached: ReadonlyMap; + /** ` (): `, one per protocol that failed. */ + readonly refused: readonly string[]; + /** The protocols no candidate reached, in the groups' order. */ + readonly unreached: readonly string[]; +} + +/** + * Group rows by protocol, in their own order, keeping at most `limit` each. + * + * @param rows - the candidates, in catalog order. + * @param protocolOf - a row's protocol, or `undefined` to leave it out. + * @param limit - the most candidates any one protocol keeps. + * @returns protocol → its first `limit` rows, in first-seen protocol order. + */ +export const candidatesByProtocol = ( + rows: readonly T[], + protocolOf: (row: T) => string | undefined, + limit: number +): Map => { + const groups = new Map(); + for (const row of rows) { + const protocol = protocolOf(row); + if (protocol === undefined) { + continue; + } + const group = groups.get(protocol) ?? []; + if (group.length < limit) { + group.push(row); + } + groups.set(protocol, group); + } + return groups; +}; + +/** + * Walk each protocol's candidates until one turn says something about the + * wire. + * + * An `entitlement` or a `gone` verdict moves to the next candidate; `ok` + * reaches the protocol; `refused` reaches it and records the failure. The + * turns run one at a time: the gateway rate-limits a burst. + * + * @param groups - protocol → candidate ids, in the order to try them. + * @param turn - one turn of `id` on `protocol`. + * @returns which protocols were reached, refused, or never reached. + */ +export const walkPerProtocol = async ( + groups: ReadonlyMap, + turn: (protocol: string, id: string) => Promise +): Promise => { + const reached = new Map(); + const refused: string[] = []; + const unreached: string[] = []; + for (const [protocol, ids] of groups) { + for (const id of ids) { + const verdict = await turn(protocol, id); + if (verdict.kind === "entitlement" || verdict.kind === "gone") { + continue; + } + reached.set(protocol, id); + if (verdict.kind === "refused") { + refused.push(`${id} (${protocol}): ${verdict.detail}`); + } + break; + } + if (!reached.has(protocol)) { + unreached.push(protocol); + } + } + return { reached, refused, unreached }; +}; diff --git a/test/support/src.ts b/test/support/src.ts index e38648ab..40ffc5e1 100644 --- a/test/support/src.ts +++ b/test/support/src.ts @@ -71,20 +71,27 @@ export { DEFAULT_USAGE_KEY_ENV, resolveConfig, } from "../../src/config.ts"; -export type { - ModelOverrideConfig, - PluginConfig, - ResolvedPluginConfig, -} from "../../src/config.ts"; +export type { PluginConfig, ResolvedPluginConfig } from "../../src/config.ts"; // cordis-context.ts export { + loadHostModules, loaderOfContext, + PI_AI_RUNTIME, + piAiFibers, readCredentialsResolver, readEntryOptions, + readLaunchEnvironment, readSessionMetaResolver, + replaceHostModuleSource, +} from "../../src/cordis-context.ts"; +export type { + CordisContext, + HostLibraries, + HostModules, + HostModuleSource, + PiAiModule, } from "../../src/cordis-context.ts"; -export type { CordisContext } from "../../src/cordis-context.ts"; // debug.ts export { recordDebug } from "../../src/debug.ts"; @@ -99,7 +106,6 @@ export type { FetchPatchHooks } from "../../src/fetch-patch.ts"; // go-discovery.ts export { - declaredProviderModels, discoverGoConfig, effectiveGoKeyRef, resolveGoApiKey, @@ -124,17 +130,42 @@ export { WINDOW_SHARE, } from "../../src/go-limits.ts"; -// google-bridge.ts +// host/describe-graft.ts export { - ambientAuthContext, - emptyCredentialStore, - GO_GOOGLE_BASE_URL, - googleProviderFor, + graftPlaneEntries, + installDescribeGraft, +} from "../../src/host/describe-graft.ts"; + +// host/host-modules.ts +export { + asPiAiModule, + defaultHostModuleSource, loadHostPi, - registerGoogleBridge, - resolveGatewayKey, -} from "../../src/google-bridge.ts"; -export type { GoogleBridgeInputs, PiRuntime } from "../../src/google-bridge.ts"; + loadLibrariesFrom, + loadPiAiModule, + wrapperEntryFor, +} from "../../src/host/host-modules.ts"; +export type { HostPi } from "../../src/host/host-modules.ts"; + +// host/google-levels.ts +export { withCatalogThinkingLevels } from "../../src/host/google-levels.ts"; +export type { + GoogleLevelPieces, + GoogleLevelSource, +} from "../../src/host/google-levels.ts"; + +// host/plane-provider.ts +export { + buildPlaneProvider, + createPlaneAdapter, + hostError, + planeApiKeyResolver, + planeAuth, + relabelEfforts, +} from "../../src/host/plane-provider.ts"; + +// host/route-claim.ts +export { installRouteClaim } from "../../src/host/route-claim.ts"; // host/patch-method.ts export { @@ -162,10 +193,12 @@ export { } from "../../src/key-capture.ts"; // lifecycle.ts -export { apply, mountInternalRoutes } from "../../src/lifecycle.ts"; +export { apply } from "../../src/lifecycle.ts"; // models-catalog.ts export { + catalogGeneration, + catalogListenerCount, catalogPlaneForRoute, enrichModelsResponse, findModelSpec, @@ -176,6 +209,7 @@ export { isGoModelsListingUrl, isModelsListingUrl, MODELS_OPENCODE_URL, + onCatalogChange, parseModelsDevCatalog, refreshCatalog, resetCatalogState, @@ -185,15 +219,8 @@ export { // models-discovery.ts export { decorateModelDiscovery, - decorateModelInfo, - decoratePlaneAdapters, - hideResponsesRoute, - isRouteRegistered, - isServableModel, mergeDiscoveredModels, - PI_THINKING_LEVELS, resolveDiscoveryProvider, - resolveEffectiveContextWindow, } from "../../src/models-discovery.ts"; // opencode-paths.ts @@ -213,17 +240,6 @@ export { summarizeCredential, } from "../../src/opencode-store.ts"; -// pi-provider.ts -export { - BASE_FOR_PROTOCOL, - baseForProtocol, - LEGACY_PLANE_API, - piModelFor, - planeBaseFor, - protocolFor, - thinkingLevelMapFor, -} from "../../src/pi-provider.ts"; - // purge-contract.ts export { parsePurgeResult } from "../../src/purge-contract.ts"; @@ -233,42 +249,6 @@ export { stripRetiredReferences, } from "../../src/purge-service.ts"; -// responses-provider.ts -export { - inheritedCredentialRef, - loadPiAi, - loadPiAiAdapterHost, - loadPiAiModule, - modelsForSdk, - narrowToDeclared, - reasoningEffortsFor, - registerResponsesProvider, - undescribedDeclaredModels, - unroutedDeclaredModels, -} from "../../src/responses-provider.ts"; - -// responses-routes.ts -export { - ANTHROPIC_ROUTE, - ANTHROPIC_SDK, - GO_GOOGLE_INTERNAL_ROUTE, - GO_RESPONSES_ROUTE, - GOOGLE_INTERNAL_ROUTE, - GOOGLE_SDK, - INTERNAL_ROUTES, - internalRouteFor, - isServableSdk, - MISTRAL_SDK, - PROTOCOL_FOR_SDK, - RESPONSES_ROUTE, - RESPONSES_SDK, - ROUTE_FOR_PLANE_PROTOCOL, - ROUTE_FOR_PROTOCOL, - SELF_MOUNTED_ROUTES, - UNSERVED_PLANE_SDKS, - UNSERVED_SDKS, -} from "../../src/responses-routes.ts"; - // session-cost.ts export { calculateTurnCost, @@ -411,13 +391,6 @@ export type { RelativeReset } from "../../src/usage-ui.ts"; // usage.ts export { GoUsageService } from "../../src/usage.ts"; -// utils/google-replay.ts -export { - googleHistoryFor, - googleReplayFor, - googleReplayStream, -} from "../../src/utils/google-replay.ts"; - // utils/guards.ts export { getAsyncIterator, @@ -429,6 +402,59 @@ export { isUnknownArray, } from "../../src/utils/guards.ts"; +// utils/model-metadata.ts +export { + canDisableReasoning, + effortName, + PI_LEVELS, + piReasoningFromEfforts, + reasoningFor, + usesCatalogThinkingLevels, +} from "../../src/utils/model-metadata.ts"; + +// utils/plane-model.ts +export { baseUrlFor, planeModelFor } from "../../src/utils/plane-model.ts"; +export type { + PlaneCatalogSpec, + PlaneModelInput, + PlanePiModel, +} from "../../src/utils/plane-model.ts"; + +// utils/plane-profile.ts +export { + hasPlaneEntries, + planeEntriesOf, + planeProfileFor, + withoutPlaneProviders, +} from "../../src/utils/plane-profile.ts"; +export type { PlaneProfileInput } from "../../src/utils/plane-profile.ts"; + +// utils/protocols.ts +export { + allOfferedCompatFields, + ANTHROPIC_SDK, + COMPAT_GATES, + compatProtocols, + FAMILY_SDK_KEY, + GO_GOOGLE_BASE_URL, + GOOGLE_SDK, + isGatedCompatField, + isPlaneRoute, + isProtocol, + isServableSdk, + LEGACY_BASE, + LEGACY_PLANE_API, + MISTRAL_SDK, + MODERN_FALLBACK, + OFFERED_COMPAT, + PLANE_ROUTES, + PROTOCOL_FOR_SDK, + protocolForSdk, + PROTOCOLS, + RESPONSES_SDK, +} from "../../src/utils/protocols.ts"; +export type { PlaneRoute, Protocol } from "../../src/utils/protocols.ts"; + // utils/request-shape.ts export { ANTHROPIC_VERSION, diff --git a/vite.config.ts b/vite.config.ts index a6d9d376..a1a17eba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -118,8 +118,8 @@ export default defineConfig({ "@deepseek-ai/cordis", "@deepseek-ai/dsh-typert-protocol", "@deepseek-ai/schemastery", - // Not a runtime dependency any more: `google-bridge.ts` loads the - // harness's own pi at runtime, so a pinned copy here would ship the + // Not a runtime dependency: `src/host/host-modules.ts` loads the + // host's own pi at runtime, so a pinned copy here would ship the // second pi this plugin deliberately stopped carrying. Kept external // for the devDependency the unit tests import directly. "@earendil-works/pi-ai", @@ -227,16 +227,6 @@ export default defineConfig({ // regression worth catching, and a threshold set above today's number would // just block every PR. Raise it whenever the suite genuinely grows. // - // `functions` came DOWN from 94 to 93 on that pass, and to 92.9 on the - // next one, and that is worth naming rather than hiding: later commits - // added defensive closures to `responses-provider.ts` (the facade's no-op - // directory handle, the property binder behind it, and the no-op handle a - // fully-taken registration is answered with) and the suite grew more - // slowly than the file did. The five functions still uncovered there are - // dead by construction — nothing calls a `dispose`/`replace` for a - // registration that was never made — so reaching them would mean making - // the test harness call a handle only it can see. That is coverage - // theatre: it buys a number rather than a behaviour. // Floors are set just below the measured value rather than at it, so a // single removed test fails the build — a ratchet, not a target. // @@ -246,40 +236,97 @@ export default defineConfig({ // on". Getting there from here is real work across 25 files, so this // raises the floor first and the per-file work follows. thresholds: { - statements: 95.9, + // Re-measured after the P-R1 review at 97.66 / 94.24 / 97.48 / 97.65, + // 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, // 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. - branches: 91.3, - functions: 95.9, - lines: 95.9, + // value rather than the other way round. 94 since P-R1 (above). + branches: 94, + functions: 97.4, + lines: 97.5, // Floors on the load-bearing modules only, so a well-covered average - // cannot hide one that stopped being exercised: the protocol mount is - // the piece with four host contracts to survive, and it must stay - // covered. Not `perFile: true` — that would hold every file to the - // aggregate number, which is a different (and much noisier) promise. - "src/responses-provider.ts": { - statements: 87, - branches: 72, - // Was 100 while the file held only the mount. See the note above: the - // same five unreachable closures are what moved it. - functions: 89, - lines: 87, + // cannot hide one that stopped being exercised. Not `perFile: true` — + // that would hold every file to the aggregate number, which is a + // different (and much noisier) promise. Every key here must name a + // file that exists: `test/architecture.test.ts` fails the build on a + // floor whose file moved or was deleted, which is a floor on nothing. + // The one way to patch a host method: every branch is a reload case. + "src/host/patch-method.ts": { + statements: 100, + branches: 100, + functions: 100, + lines: 100, }, - "src/responses-routes.ts": { + // The plane routes' pure decisions (P-R1): every branch is a protocol, + // a precedence or one of pi-ai's checks, so all of them are covered. + "src/utils/protocols.ts": { statements: 100, branches: 100, functions: 100, lines: 100, }, - // The one way to patch a host method: every branch is a reload case. - "src/host/patch-method.ts": { + "src/utils/model-metadata.ts": { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + "src/utils/plane-model.ts": { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + "src/utils/plane-profile.ts": { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + // The plane routes' host half, measured rather than aspirational: what + // is left uncovered are guards for host shapes no composition offers. + "src/host/route-claim.ts": { + statements: 97, + branches: 86, + functions: 95, + lines: 97, + }, + // The Google level wrapper (D16): every branch is either pi's own path + // or one step of pi's level path, so all of them are covered. + "src/host/google-levels.ts": { statements: 100, branches: 100, functions: 100, lines: 100, }, + "src/host/plane-provider.ts": { + statements: 99, + branches: 97, + functions: 100, + lines: 99, + }, + "src/host/describe-graft.ts": { + statements: 100, + branches: 94, + 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 + // shapes no install produces (a manifest that is not an object, a + // module namespace that is not a record) and a host with no wrapper + // reachable from anywhere. + "src/host/host-modules.ts": { + statements: 96, + branches: 92, + functions: 100, + lines: 96, + }, }, }, },