From c7121a34a112aba804c1f39922e8292e50798d08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:31:47 +0000 Subject: [PATCH 01/20] feat: the plugin owns the opencode and opencode-go routes The plugin now serves the opencode and opencode-go routes itself, through supported host paths only, and the hidden-route workaround is gone. A prepended internal/config listener hands pi-ai a copy of its config without the two plane entries, so pi-ai keeps every other route. On a commit the plugin reconciles: it builds one plane adapter, a subclass of the host's PiAiAdapter, over pure profiles (utils/plane-profile, plane-model, model-metadata and protocols) and registers only the planes that are declared. A Settings candidate has the plane entries it changes validated strictly. A read-only settings.describe graft puts the committed plane entries back, so the host still sees them. Deleted: responses-provider, responses-routes, google-bridge, pi-provider and utils/google-replay; the listing hide and the discovery decoration; the stream redirect; the D19 settings; and the hidden ids in DEFAULT_PROVIDERS and cordis.patch.yml. What users see: any saved id is accepted and served, and an id no catalog knows dispatches as completions. The protocol comes from the catalog and model info follows the user's own row first. A model whose reasoning is only on or off offers Off/On. The default endpoint is the per-protocol inference family. Keys resolve by pi-ai's own rule, and a plane lists its declared ids even when no key resolves. When several pi-ai rows declare planes, the row the plugin serves stays the same while it is live and still declares one, so no row is left stripped with nobody serving it. If the host's pi-ai module cannot be read, the plugin steps aside even before any plane is declared, so a later Settings save goes to pi-ai's own check. The characterization rows this changes are flipped to new baselines; every other row stays green unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- .github/dependabot.yml | 6 +- .github/workflows/ci.yml | 25 +- AGENTS.md | 30 +- CONTRIBUTING.md | 24 +- README.md | 125 +- README.zh-CN.md | 123 +- cordis.patch.yml | 16 +- docs/credentials.md | 21 +- docs/deep-dive.md | 14 +- docs/improvement-plan.md | 26 +- docs/protocol-routing-and-merge.md | 123 +- docs/redirect-planes.md | 98 +- scripts/verify-catalog.ts | 78 +- src/attachment-services.ts | 2 +- src/config-values.ts | 46 +- src/config.ts | 207 +-- src/cordis-context.ts | 210 ++- src/go-discovery.ts | 114 -- src/google-bridge.ts | 614 --------- src/host/describe-graft.ts | 110 ++ src/host/host-modules.ts | 583 +++++++++ src/host/plane-provider.ts | 568 +++++++++ src/host/route-claim.ts | 812 ++++++++++++ src/identity.ts | 11 +- src/index.ts | 2 - src/lifecycle.ts | 225 +--- src/models-catalog.ts | 55 +- src/models-discovery.ts | 1035 +-------------- src/pi-provider.ts | 445 ------- src/responses-provider.ts | 1215 ------------------ src/responses-routes.ts | 297 ----- src/settings-copy.ts | 8 +- src/settings-fields.ts | 19 +- src/stream-hook.ts | 130 +- src/utils/google-replay.ts | 61 - src/utils/model-metadata.ts | 236 ++++ src/utils/plane-model.ts | 443 +++++++ src/utils/plane-profile.ts | 575 +++++++++ src/utils/protocols.ts | 285 +++++ src/utils/request-shape.ts | 8 +- test/architecture.test.ts | 73 +- test/attachment-services.test.ts | 109 +- test/catalog-change.test.ts | 86 ++ test/catalog.test.ts | 245 ++-- test/composition.test.ts | 46 +- test/config-values.test.ts | 11 +- test/config.test.ts | 239 +--- test/deleted-settings.test.ts | 87 +- test/describe-graft.test.ts | 143 +++ test/e2e/dispatch-levels.e2e.ts | 506 +++----- test/e2e/host-binary.e2e.ts | 217 ++-- test/e2e/host/probe-plugin.mjs | 137 +- test/e2e/keyless-matrix.e2e.ts | 17 +- test/e2e/protocol-routing.e2e.ts | 281 ++-- test/fetch-patch.test.ts | 10 +- test/go-discovery.test.ts | 83 +- test/go-google-prepare.test.ts | 118 -- test/go-google.test.ts | 291 ----- test/google-bridge.test.ts | 535 -------- test/google-replay.test.ts | 61 - test/google-runtime-fixture.ts | 11 - test/host-characterization.test.ts | 509 ++++---- test/host-editor-contracts.test.ts | 117 ++ test/host-harness.test.ts | 92 +- test/host-modules.test.ts | 441 +++++++ test/host-planes.test.ts | 845 ++++++++++++ test/host-shapes.test.ts | 63 + test/host-versions.test.ts | 4 +- test/keyless-matrix.test.ts | 23 +- test/lifecycle.test.ts | 493 +------ test/mock/helpers.ts | 11 +- test/mock/plane-dispatch.mock.ts | 514 ++++++++ test/mock/protocol-compat.mock.ts | 25 +- test/model-metadata.test.ts | 214 ++++ test/models-discovery.test.ts | 1576 +---------------------- test/mount-protocols.test.ts | 119 +- test/pi-provider.test.ts | 455 ------- test/picker-integration.test.ts | 198 --- test/plane-model.test.ts | 609 +++++++++ test/plane-profile.test.ts | 448 +++++++ test/plane-provider.test.ts | 638 ++++++++++ test/protocols.test.ts | 173 +++ test/public-api.test.ts | 7 +- test/reload.test.ts | 457 +++---- test/request-shape.test.ts | 23 +- test/responses-provider.test.ts | 1907 ---------------------------- test/responses-routes.test.ts | 311 ----- test/route-claim.test.ts | 908 +++++++++++++ test/settings-page.test.tsx | 11 +- test/stream-hook.test.ts | 139 +- test/support/host.ts | 486 +++++-- test/support/keyless-matrix.ts | 51 +- test/support/src.ts | 174 +-- vite.config.ts | 101 +- 94 files changed, 11702 insertions(+), 12771 deletions(-) delete mode 100644 src/google-bridge.ts create mode 100644 src/host/describe-graft.ts create mode 100644 src/host/host-modules.ts create mode 100644 src/host/plane-provider.ts create mode 100644 src/host/route-claim.ts delete mode 100644 src/pi-provider.ts delete mode 100644 src/responses-provider.ts delete mode 100644 src/responses-routes.ts delete mode 100644 src/utils/google-replay.ts create mode 100644 src/utils/model-metadata.ts create mode 100644 src/utils/plane-model.ts create mode 100644 src/utils/plane-profile.ts create mode 100644 src/utils/protocols.ts create mode 100644 test/catalog-change.test.ts create mode 100644 test/describe-graft.test.ts delete mode 100644 test/go-google-prepare.test.ts delete mode 100644 test/go-google.test.ts delete mode 100644 test/google-bridge.test.ts delete mode 100644 test/google-replay.test.ts delete mode 100644 test/google-runtime-fixture.ts create mode 100644 test/host-modules.test.ts create mode 100644 test/host-planes.test.ts create mode 100644 test/mock/plane-dispatch.mock.ts create mode 100644 test/model-metadata.test.ts delete mode 100644 test/pi-provider.test.ts delete mode 100644 test/picker-integration.test.ts create mode 100644 test/plane-model.test.ts create mode 100644 test/plane-profile.test.ts create mode 100644 test/plane-provider.test.ts create mode 100644 test/protocols.test.ts delete mode 100644 test/responses-provider.test.ts delete mode 100644 test/responses-routes.test.ts create mode 100644 test/route-claim.test.ts 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 2f063121..31618b4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -291,17 +291,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 0eff0cdc..b2665507 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ 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/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`. @@ -73,12 +73,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. @@ -91,13 +96,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. -- `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` — 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. - `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. -- `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. +- `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. @@ -113,7 +117,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 b0b4b2c0..ea955995 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,9 +42,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. +- `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, and skips without the plane's key or when no toggle model was entitled on the account. - `refusal-classification.e2e.ts` — the refusal classifier's verdicts on the gateway's bytes over a real `node:http` socket. - `host-binary.e2e.ts` — the published host binary; see [The real-host harness](#the-real-host-harness). It skips unless `DSH_HOST_E2E=1`. @@ -54,29 +54,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, so the tags in `S0_TODAY` are what govern which entries a phase may flip. 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 profile's credentials file, 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 profile's credentials file, 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 to `HOST_VERSIONS.pi` with npm `overrides` and applies the harness patch with `patch -p1`, then asserts every row. `floating` takes the pi npm resolves for the published host. It skips the 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 to `HOST_VERSIONS.pi` with npm `overrides` and applies the harness patch with `patch -p1`, then asserts every row. `floating` takes the pi npm resolves for the published host. It skips 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: @@ -127,7 +127,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` and the four pure plane modules (`utils/protocols.ts`, `utils/model-metadata.ts`, `utils/plane-model.ts`, `utils/plane-profile.ts`) at 100%, because each branch is a reload case, a protocol, a precedence or one of pi-ai's checks; `host/route-claim.ts`, `host/plane-provider.ts` and `host/describe-graft.ts` just below what they measure. `test/architecture.test.ts` fails the build on a floor whose file no longer exists, which would otherwise be a floor on nothing. ## Release process (maintainers) diff --git a/README.md b/README.md index 593ee789..8913e007 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. @@ -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) --- @@ -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-contributor`, 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`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `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`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `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-contributor`, 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)_ | 25 | 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)_ | 25 | OpenAI Chat Completions | +| `@ai-sdk/openai` | 31 | OpenAI Responses | +| `@ai-sdk/anthropic` | 17 | Anthropic Messages | +| `@ai-sdk/google` | 8 | Google Generative AI | +| `@ai-sdk/mistral` | 1 | OpenAI Chat Completions, as pi's own data files it | Counts are the **82** active `opencode` models in `models.opencode.ai` as of 2026-10-23 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 82 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. -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. +- **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. 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,16 @@ 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`. +- **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 +441,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 2b47275d..6cc1adf4 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)原样通过。 **三条主线。** @@ -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. [署名与许可](#-署名与许可) --- @@ -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-contributor`,由内部适配器 `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`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `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`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `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-contributor`,支持图片附件和带签名的工具调用回放。Go 没有旧版 Google 接口,因此两个家族都使用这一个地址。 - 由实时三窗口额度计量监控(5 小时滚动、每周、每月)。完整集合随内置目录发布——参见[权威模型目录](#7-权威模型目录双本地预置--实时-swr-更新)。 ### 3. 模型的协议是怎么定的 —— 以及为什么你什么都不用配 Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在该模型需要不同 SDK 时**才逐模型标注 `provider.npm` —— 所以这个字段的**存在本身就是信号**,它决定走哪条线路协议。没有任何手工匹配: -| models.dev `provider.npm` | 模型数 | 协议 | 服务自 | -| :-- | --: | :-- | :-- | -| _(缺失)_ | 27 | 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` | 模型数 | 协议 | +| :-- | --: | :-- | +| _(缺失)_ | 25 | OpenAI Chat Completions | +| `@ai-sdk/openai` | 31 | OpenAI Responses | +| `@ai-sdk/anthropic` | 17 | Anthropic Messages | +| `@ai-sdk/google` | 8 | Google Generative AI | +| `@ai-sdk/mistral` | 1 | OpenAI Chat Completions(与 pi 自己的数据一致) | 数量为 `models.opencode.ai` 中 `opencode` 的 82 个**在用**模型,统计于 2026-10-23 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 82 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 -插件把这两条内部路由同时挡在模型选择器和 _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 的条目。 +- **只有开/关两种推理的模型提供“关 / 开”。** 只有该模型的协议确实能关闭推理时才提供“关”;有真正档位列表的模型只提供它自己的档位。 +- **一份凭据,按宿主自己的规则。** 平面的认证方式与 `llm-pi-ai` 相同:条目的 `apiKeyEnv` 经凭据服务解析(未挂载凭据服务时读启动环境),保存在 `llm-pi-ai/opencode` 下的登录记录,否则用 pi 自己的 `OPENCODE_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 从平面的模型列表中删掉,编辑即可通过。 + ## 🛠 故障排查 | 症状 | 可能原因 | 解决方法 | @@ -406,7 +394,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 | `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`) | +| 目录中的模型在 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,16 @@ 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`。 +- **保存的任何 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 +431,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..9454f899 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -8,13 +8,18 @@ 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) + +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,7 +37,7 @@ 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. @@ -72,7 +77,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 +146,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 +256,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 fbd5184a..b1591af5 100644 --- a/docs/deep-dive.md +++ b/docs/deep-dive.md @@ -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 fails when the shim is stale. 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: DSH asks the route's own adapter first, and for a plane route the plugin's plane adapter answers the way `llm-pi-ai` does, from pi's 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, 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 `OPENCODE_API_KEY`/`OPENCODE_GO_API_KEY` lookup. `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 cb3939d8..54fdc09b 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 | @@ -358,7 +358,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**: session-controller does not export the function (its `exports` map reaches `SessionController` and `buildModelCatalog` only), so `test/support/host.ts` rebuilds it line for line (`providerWithApiKey`) from the real parts tier B holds — `settings.describe({ redactSecrets: true })`, `llm.listConfigurableProviders()` and `credentials.describe` — and 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 +389,7 @@ 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. 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 +568,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 +580,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/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index 0d7ef7d8..6dfd1fb0 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**: 25 of 82 | the model names **no** SDK | -| OpenAI **Responses** | `POST /zen/v1/responses` | 31 of 82 | `@ai-sdk/openai` | -| Anthropic **Messages** | `POST /zen/v1/messages` | 17 of 82 | `@ai-sdk/anthropic` | -| Google **Generative AI** | `POST /zen/v1/models/:streamGenerateContent` | 8 of 82 | `@ai-sdk/google` | -| Mistral **Conversations** | `POST /zen/v1/chat/completions` | 1 of 82 | `@ai-sdk/mistral` | +| OpenAI **Chat Completions** | `POST /inference/openai/v1/chat/completions` | **the default**: 25 of 82 | the model names **no** SDK | +| OpenAI **Responses** | `POST /inference/openai/v1/responses` | 31 of 82 | `@ai-sdk/openai` | +| Anthropic **Messages** | `POST /inference/anthropic/v1/messages` | 17 of 82 | `@ai-sdk/anthropic` | +| Google **Generative AI** | `POST /inference/google/v1beta/models/:streamGenerateContent` | 8 of 82 | `@ai-sdk/google` | +| OpenAI **Chat Completions** (as pi files it) | `POST /inference/openai/v1/chat/completions` | 1 of 82 | `@ai-sdk/mistral` | -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/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 559471dd..5cb11c30 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -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` | 25 | 17 | -| `@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` | 25 | 17 | +| `@ai-sdk/openai` | `openai-responses` | 31 | **5** | +| `@ai-sdk/anthropic` | `anthropic-messages` | 17 | **5** | +| `@ai-sdk/mistral` | `openai-completions` | 1 | — | +| `@ai-sdk/google` | `google-generative-ai` | 8 | **1** | **Absence is the signal, and it is a real one.** Both planes set a provider-level `npm` of `@ai-sdk/openai-compatible` in `models.dev`, and **no model declares it explicitly**: 0 of 82 on Zen, 0 of 28 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 12 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 12 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,18 @@ 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`). -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`. 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..44ddeb29 100644 --- a/src/cordis-context.ts +++ b/src/cordis-context.ts @@ -12,7 +12,10 @@ */ import { readString } from "./config-values.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. @@ -101,14 +104,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, @@ -394,3 +398,203 @@ 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>>; + /** `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/host-modules.ts b/src/host/host-modules.ts new file mode 100644 index 00000000..e1fcf949 --- /dev/null +++ b/src/host/host-modules.ts @@ -0,0 +1,583 @@ +/** + * 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"; + +/** 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", + headers: "dist/providers/opencode-headers.js", + models: "dist/models.js", + responses: "dist/api/openai-responses.lazy.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; + } +}; + +/** + * 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, + 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("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"); + 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"] }), + }; + /* 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..53d9b384 --- /dev/null +++ b/src/host/plane-provider.ts @@ -0,0 +1,568 @@ +/** + * 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"; + +/** 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). + * + * @param libraries - the host's pi. + */ +export const buildPlaneProvider = + (libraries: HostLibraries): PlaneProviderBuilder => + ({ id, models, name }) => + libraries.createProvider({ + api: Object.fromEntries( + PROTOCOLS.map((protocol) => [ + protocol, + libraries.withOpenCodeSessionHeader(libraries.apis[protocol]()), + ]) + ), + 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..86728a70 --- /dev/null +++ b/src/host/route-claim.ts @@ -0,0 +1,812 @@ +/** + * 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. + * - **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, + 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; + 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; + } + const { modules } = state; + 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()); + 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..adfc8f84 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: @@ -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 工具声明以满足网关校验。留空沿用默认值。", 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..93610897 --- /dev/null +++ b/src/utils/model-metadata.ts @@ -0,0 +1,236 @@ +/** + * 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". + * + * 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 } 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 levels a catalog spec offers, and whether it is a bare switch. */ +const offeredLevels = ( + spec: ReasoningSpec +): { levels: PiLevel[]; toggle: boolean } => { + const options = spec.reasoning_options ?? []; + const values = options.find( + (option) => option.type === "effort" && (option.values?.length ?? 0) > 0 + )?.values; + if (values !== undefined) { + const levels = PI_LEVELS.filter( + (level) => level !== "off" && values.includes(level) + ); + if (levels.length > 0) { + return { levels, toggle: false }; + } + } + 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. `none`/`off` in it never + * offer Off: those models do not take an explicit disable. + * - 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 { levels, toggle } = offeredLevels(spec); + const off = toggle && canDisableReasoning(protocol, compat); + const thinkingLevelMap: Record = off + ? {} + : { off: null }; + for (const level of PI_LEVELS) { + if (level !== "off") { + thinkingLevelMap[level] = levels.includes(level) ? level : null; + } + } + const reasoningEfforts: Record = off + ? { off: null } + : {}; + const efforts: { id: string; name: string }[] = off + ? [{ 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..e5f8a450 --- /dev/null +++ b/src/utils/plane-profile.ts @@ -0,0 +1,575 @@ +/** + * 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 { + 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>[]; +}) => 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) + : []; + +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 { + piProvider = input.buildProvider({ + id: route, + models: models.filter((model) => !modelErrors.has(String(model.id))), + 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 2f9474fd..eac12697 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); @@ -756,3 +763,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 a5b60128..f0953937 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-contributor" }] }, + 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,26 +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-contributor" - ), - 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); }); @@ -110,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" @@ -138,35 +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-contributor" - ), - 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-contributor", messages: [ { @@ -184,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 4d89c07d..5cb48e97 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 = { @@ -1117,131 +1093,130 @@ 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" + it("sends the same SDK to a different base per plane", () => { + expect(servedOn("opencode", "gpt-5.4")).toMatchObject({ + api: "openai-responses", + baseUrl: "https://opencode.ai/inference/openai/v1", + }); + expect(servedOn("opencode-go", "grok-4.7")).toMatchObject({ + api: "openai-responses", + baseUrl: "https://opencode.ai/inference/go/openai/v1", + }); + expect(servedOn("opencode-go", "minimax-m3")).toMatchObject({ + api: "anthropic-messages", + baseUrl: "https://opencode.ai/inference/go/anthropic", + }); + expect(servedOn("opencode-go", "gemini-4-argon-contributor")).toMatchObject( + { + api: "google-generative-ai", + baseUrl: "https://opencode.ai/inference/go/google/v1beta", + } ); - 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-contributor", - "@ai-sdk/google" - ) - ).toBe("opencode-go-google"); - expect(internalRouteFor("opencode-go", "grok-4.7")).toBeUndefined(); }); }); /** - * 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..8ba6047b 100644 --- a/test/e2e/dispatch-levels.e2e.ts +++ b/test/e2e/dispatch-levels.e2e.ts @@ -2,25 +2,32 @@ * 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. It skips + * without the plane's key, and when no toggle model was entitled on the + * account (none reached the gateway's body check). + * + * Opt-in (`OPENCODE_E2E=1`), like every file in this suite. The sweep needs + * no network and no port: its value is the real rule and the real catalog. */ import { readFile } from "node:fs/promises"; @@ -29,130 +36,81 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/models"; import { describe, expect, it } from "vitest"; import { + drainStream, + fakeCredentials, + mountTierA, + waitForPlaneOwner, +} from "../support/host.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 +137,142 @@ 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 first toggle-only model of each protocol on a plane: its efforts are named "On". */ +const toggleModels = ( + plane: (typeof PLANES)[number] +): { spec: CatalogModelSpec; protocol: Protocol }[] => { + const seen = new Map(); + for (const spec of plane.catalog) { + const protocol = protocolForSdk(spec.provider_npm); + if (protocol === undefined || seen.has(protocol)) { + continue; + } + const reasoning = reasoningFor(spec, protocol); + if (reasoning?.efforts.some((effort) => effort.name === "On") === true) { + seen.set(protocol, spec); + } + } + return [...seen].map(([protocol, spec]) => ({ protocol, spec })); +}; - 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?.(); - } - }); +/** 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("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?.(); - } - }); +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("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" - ); - expect(unknown).toBe(fixture.answers.get("not-in-any-catalog")); - } finally { - stop?.(); + it.for(PLANES)( + "$route: every toggle-only protocol accepts On through the plane adapter", + { timeout: 120_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.length).toBeGreaterThan(0); + 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: toggles.map(({ spec }) => ({ id: spec.id })), + }, + }, + }); + try { + await waitForPlaneOwner(tier.llm, [plane.route]); + const refused: string[] = []; + let reached = 0; + for (const { protocol, spec } of toggles) { + // The id the picker's "On" carries, read from what the adapter + // answers rather than assumed. + const info = (await tier.llm.resolveModelInfo( + plane.route, + spec.id + )) as { reasoning?: { efforts?: { id: string; name: string }[] } }; + const on = info.reasoning?.efforts?.find( + (effort) => effort.name === "On" + )?.id; + expect(on, `${spec.id} offers no On`).toBeDefined(); + const drained = await drainStream(tier.llm, plane.route, spec.id, { + reasoningEffort: on, + signal: AbortSignal.timeout(60_000), + }); + const message = drained.failureMessage ?? drained.error ?? ""; + if (ENTITLEMENT.test(message)) { + continue; + } + reached += 1; + if ( + drained.error !== undefined || + drained.finish?.kind === "error" + ) { + refused.push(`${spec.id} (${protocol}): ${message}`); + } + } + // Every toggle model refused on entitlement: nothing about the wire + // was observed, which is a skip, never a pass. + context.skip( + reached === 0, + "no toggle model was entitled on this account" + ); + expect(refused).toEqual([]); + } 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 76fcf5e2..be40dea0 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 ?? ""; @@ -81,14 +73,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< @@ -98,6 +96,15 @@ interface ProbeOutput { readonly callConfig: Record; readonly dispatch: 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[]; @@ -105,7 +112,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. */ @@ -202,6 +213,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 = ""; describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { beforeAll(async () => { @@ -227,6 +240,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) { @@ -322,10 +336,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), }, @@ -382,68 +397,74 @@ 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-contributor"]?.sent + ).toEqual([ + "https://opencode.ai/inference/go/google/v1beta/models/gemini-4-argon-contributor:streamGenerateContent?alt=sse", + ]); + expect(out.callConfig["opencode-go/gemini-4-argon-contributor"]).toBe( + "ok" + ); + } + ); it("[BASELINE] the settings page reads every plane the profile declares", () => { expect(out.describe?.value.toSorted()).toEqual([ @@ -453,26 +474,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..f5ed3664 100644 --- a/test/e2e/host/probe-plugin.mjs +++ b/test/e2e/host/probe-plugin.mjs @@ -21,33 +21,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); @@ -204,6 +232,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 +251,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 +269,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 +293,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 +302,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" @@ -298,9 +325,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,8 +384,10 @@ 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, }; }; 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 1a94668c..4d55b19f 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,8 +45,17 @@ * 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. + * * 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 */ @@ -53,31 +63,34 @@ import { describe, expect, it } from "vitest"; import { - GOOGLE_INTERNAL_ROUTE, + 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, } 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,13 +109,13 @@ 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. */ +/** + * The path each protocol uses against an OVERRIDDEN base, which is read as a + * legacy-family base (`…/zen/v1`): one base for every protocol. + */ const PATH_FOR: Readonly> = { "anthropic-messages": "/messages", // The Gemini path carries the MODEL in the URL, not the body, so the probe @@ -112,6 +125,18 @@ const PATH_FOR: Readonly> = { "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. + */ +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. * @@ -170,18 +195,51 @@ interface Probe { readonly summary: string; } +/** + * 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 urlFor = ( + plane: "go" | "zen", + protocol: string, + 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); + if (override !== undefined) { + return `${override}${path}`; + } + 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, - base: string = ZEN_BASE + plane: "go" | "zen" = "zen" ): Promise => { const template = 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}") @@ -193,7 +251,7 @@ const probe = async ( : (AUTH_FOR[protocol]?.(key) ?? {}); let response: Response; try { - response = await fetch(`${base}${path}`, { + response = await fetch(urlFor(plane, protocol, model), { body: JSON.stringify(payload), headers: { ...PLUGIN_HEADERS, ...auth }, method: "POST", @@ -275,55 +333,48 @@ const accessDisabled = (result: Probe): boolean => // so nothing about the route was observed either way. result.status === 0; -/** - * 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. - * - * 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. - */ -/** The route id each plane is reached by. */ -const ROUTE_FOR_PLANE = { go: "opencode-go", zen: "opencode" } as const; +/** 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 a model is served on, ON ITS OWN PLANE. + * 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. * - * 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. + * `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. */ -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. @@ -342,10 +393,10 @@ describe.skipIf(!LIVE)("live protocol routing", () => { candidates: readonly string[], protocol: string, key?: string, - base: string = ZEN_BASE + plane: "go" | "zen" = "zen" ): 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) && answer.status !== 0) { return model; } @@ -369,9 +420,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)) { @@ -441,9 +493,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)) { @@ -487,7 +540,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)) { @@ -560,9 +616,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); @@ -652,10 +707,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(); @@ -690,12 +746,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); @@ -703,18 +760,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 @@ -723,13 +778,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) => { @@ -749,14 +807,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. @@ -765,6 +826,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", () => { @@ -794,6 +889,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( 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 71c746f7..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-contributor"; -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 1f7e114c..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-contributor"; -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-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 70d96a3c..06c1722f 100644 --- a/test/host-characterization.test.ts +++ b/test/host-characterization.test.ts @@ -1,36 +1,38 @@ /** - * 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, 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. + * generates one test per model and facet titled with that tag. * - * 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 { @@ -39,10 +41,12 @@ import { APPLY_ORDERS, bootTierB, CHARACTERIZED_MODELS, + CHARACTERIZED_PROFILE, characterizedProviders, drainStream, fakeCredentials, PLANES, + piAiRestarts, piKnows, providerWithApiKey, recordingFetch, @@ -50,27 +54,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 => { @@ -84,18 +90,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"); @@ -117,14 +160,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 => { @@ -208,28 +246,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) - ), - }, - }, - }; - }; - /** One generated case per row and facet, titled with the facet's own tag. */ const FACET_CASES = ROW_KEYS.flatMap((key) => (["info", "callConfig", "wire"] as const).map((facet) => ({ @@ -248,13 +264,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(); @@ -275,8 +291,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); @@ -312,25 +337,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); @@ -344,30 +372,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; @@ -377,28 +411,20 @@ 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, and todayRowsFor refuses an order it does not know", () => { - // 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, and todayRowsFor answers both orders the same and refuses one it does not know", () => { 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); } // 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 ); @@ -408,71 +434,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-contributor" 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-contributor"); - 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); } }); @@ -480,7 +485,7 @@ describe( expect(await measureFacet(facet, key)).toEqual(expectedFacet(facet, key)); }); - 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" @@ -490,20 +495,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]); @@ -511,60 +529,55 @@ 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-contributor: resolveCallConfig refuses it (INVALID_CONFIG) while the turn is served on the Go Google base through the bridge", async () => { + it("[BASELINE] Go gemini-4-argon-contributor validates and is sent on Go's Google base with the Go key (F10)", async () => { expect( await callConfigOf("opencode-go", "gemini-4-argon-contributor") - ).toBe("INVALID_CONFIG"); + ).toBe("ok"); const { drained, requests } = await sentBy( "opencode-go", "gemini-4-argon-contributor" @@ -577,42 +590,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-contributor"); - 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); }); } ); @@ -627,46 +651,28 @@ 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" }); } }); - it("[KNOWN_DEFECT] the Go Gemini verdict depends on the apply order: decorated, gemini-4-argon-contributor validates, and no other facet of any row moves (decoratePlaneAdapters in src/models-discovery.ts)", async () => { - // The plan expected INVALID_CONFIG for Go Gemini; that holds in the - // published host's order (above) and not in this one, where the wrapped - // plane adapter delegates to opencode-go-google. todayRowsFor is the - // table the binary e2e picks when its probe infers this order. + it("[BASELINE] no facet depends on the apply order any more: every row measured in this order equals S0_TODAY", async () => { const expected = todayRowsFor("pi-ai-first"); - expect( - ROW_KEYS.filter( - (key) => - JSON.stringify(expected[key]) !== JSON.stringify(S0_TODAY.rows[key]) - ) - ).toEqual(["opencode-go/gemini-4-argon-contributor"]); + expect(expected).toEqual(S0_TODAY.rows); for (const facet of ["info", "callConfig", "wire"] as const) { const measured: Record = {}; const want: Record = {}; @@ -684,7 +690,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 }[] }; @@ -696,12 +702,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 1c9bd1e7..6d76d480 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"; @@ -420,3 +426,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..d59e2bbb --- /dev/null +++ b/test/host-modules.test.ts @@ -0,0 +1,441 @@ +/** + * 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 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..9f588ab1 --- /dev/null +++ b/test/host-planes.test.ts @@ -0,0 +1,845 @@ +/** + * 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, + inject, + name, + recordCapturedApiKey, +} from "./support/src.ts"; + +/** The ids of a listing, in order. */ +const modelIds = (models: readonly { id: string }[]): string[] => + models.map((model) => model.id); + +let tierA: TierA | undefined; +let tierB: TierB | undefined; +let wire: RecordingFetch; + +afterEach(async () => { + try { + await tierA?.dispose(); + await tierB?.dispose(); + } 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 ?? {} }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + expect(routeIds(tier.llm)).toEqual(["deepseek"]); + const zen = s0Providers().opencode ?? {}; + await tier.ctx.configEditor.edit( + tier.piEntry, + editProvider("opencode", zen) + ); + await waitForPlaneOwner(tier.llm, ["opencode"]); + await tier.ctx.configEditor.edit( + tier.piEntry, + removeProviders("opencode", "opencode-go") + ); + await until( + () => routeIds(tier.llm).length === 1, + "the last plane is withdrawn" + ); + expect(routeIds(tier.llm)).toEqual(["deepseek"]); + await tier.ctx.configEditor.edit( + tier.piEntry, + editProvider("opencode", zen) + ); + await waitForPlaneOwner(tier.llm, ["opencode"]); + }); +}); + +describe("host planes · credentials (tier A, D20)", { timeout: 30_000 }, () => { + const mountWith = async ( + credentials: ReturnType, + entry: Record = {} + ): Promise => { + offline(); + tierA = await mountTierA({ + credentials, + plugin: {}, + providers: { + opencode: { models: [{ id: "deepseek-v4-pro" }], ...entry }, + }, + }); + await waitForPlaneOwner(tierA.llm, ["opencode"]); + return tierA; + }; + + it("with no apiKeyEnv, pi's own discovery reads the credentials service's value", async () => { + const tier = await mountWith( + fakeCredentials({ refs: { OPENCODE_API_KEY: "svc-k" } }) + ); + const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(requests[0]?.headers.authorization).toBe("Bearer svc-k".length); + }); + + it("a Sign-in record under llm-pi-ai/opencode authenticates the plane", async () => { + const credentials = fakeCredentials({ + records: { "llm-pi-ai/opencode": { key: "stored-k", kind: "api-key" } }, + }); + const tier = await mountWith(credentials); + const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(requests[0]?.headers.authorization).toBe("Bearer stored-k".length); + expect(credentials.calls).toContainEqual([ + "readRecord", + "llm-pi-ai/opencode", + ]); + }); + + describe("OpenCode's own store, opted into, is the LAST resort", () => { + const stores: FixtureStore[] = []; + + afterEach(() => { + for (const store of stores.splice(0)) { + store.cleanup(); + } + vi.unstubAllEnvs(); + }); + + /** A store holding a Zen key, and the plugin opted into reading it. */ + const mountOptedIn = async ( + credentials: ReturnType + ): Promise => { + const store = createOpencodeStore([ + { integrationId: "opencode", value: "oc_sk_from_the_store" }, + ]); + stores.push(store); + vi.stubEnv("XDG_DATA_HOME", store.root); + offline(); + tierA = await mountTierA({ + credentials, + plugin: { readOpencodeStore: true }, + providers: { opencode: { models: [{ id: "deepseek-v4-pro" }] } }, + }); + await waitForPlaneOwner(tierA.llm, ["opencode"]); + return tierA; + }; + + it("answers when pi's own lookup finds nothing (the known answer)", async () => { + const tier = await mountOptedIn(fakeCredentials()); + const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(requests[0]?.headers.authorization).toBe( + "Bearer oc_sk_from_the_store".length + ); + }); + + it("never pre-empts an OPENCODE_API_KEY the credentials service holds", async () => { + const tier = await mountOptedIn( + fakeCredentials({ refs: { OPENCODE_API_KEY: "svc-k" } }) + ); + const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(requests[0]?.headers.authorization).toBe("Bearer svc-k".length); + }); + + it("never pre-empts a Sign-in record under llm-pi-ai/opencode", async () => { + const tier = await mountOptedIn( + fakeCredentials({ + records: { + "llm-pi-ai/opencode": { key: "stored-k", kind: "api-key" }, + }, + }) + ); + const { requests } = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(requests[0]?.headers.authorization).toBe("Bearer stored-k".length); + }); + }); + + it("a named ref that resolves nothing still lists the models, and the turn fails MISSING_CREDENTIAL without a request", async () => { + const tier = await mountWith(fakeCredentials(), { + apiKeyEnv: "OPENCODE_API_KEY", + }); + expect(modelIds(await tier.llm.listModels("opencode"))).toEqual([ + "deepseek-v4-pro", + ]); + const { drained, requests } = await turn( + tier.llm, + "opencode", + "deepseek-v4-pro" + ); + expect(drained.finish).toEqual({ + code: "MISSING_CREDENTIAL", + kind: "error", + }); + expect(requests).toEqual([]); + }); + + it("a credentials-service failure propagates rather than reading as a missing key", async () => { + const credentials = fakeCredentials(); + (credentials.service as Record).resolve = () => + Promise.reject(new Error("vault down")); + const tier = await mountWith(credentials, { + apiKeyEnv: "OPENCODE_API_KEY", + }); + await expect(tier.llm.listModels("opencode")).rejects.toThrow( + /vault down/u + ); + const { drained, requests } = await turn( + tier.llm, + "opencode", + "deepseek-v4-pro" + ); + // The turn fails on the service's own error, and nothing is sent: a + // swallowed rejection would read as a missing key or go out keyless. + expect(requests).toEqual([]); + expect(drained.finish?.kind).toBe("error"); + expect(drained.finish?.code).not.toBe("MISSING_CREDENTIAL"); + expect(drained.failureMessage ?? drained.error).toMatch(/vault down/u); + }); + + it("a rotated key takes effect on the next turn", async () => { + const credentials = fakeCredentials({ refs: { OPENCODE_API_KEY: "k1" } }); + const tier = await mountWith(credentials, { + apiKeyEnv: "OPENCODE_API_KEY", + }); + const first = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + await ( + credentials.service.set as unknown as ( + ref: string, + value: string + ) => Promise + )("OPENCODE_API_KEY", "k2-longer"); + const second = await turn(tier.llm, "opencode", "deepseek-v4-pro"); + expect(first.requests[0]?.headers.authorization).toBe("Bearer k1".length); + expect(second.requests[0]?.headers.authorization).toBe( + "Bearer k2-longer".length + ); + }); + + it("a captured key never answers for a named ref that resolves nothing", async () => { + recordCapturedApiKey("opencode", "cap"); + const tier = await mountWith(fakeCredentials(), { + apiKeyEnv: "OPENCODE_API_KEY", + }); + const { drained, requests } = await turn( + tier.llm, + "opencode", + "deepseek-v4-pro" + ); + expect(drained.finish?.code).toBe("MISSING_CREDENTIAL"); + expect(requests).toEqual([]); + }); +}); + +describe("host planes · isolation (tier A)", { timeout: 30_000 }, () => { + it("a sibling's headers, base, api, reasoning and retry never reach a plane", async () => { + offline(); + tierA = await mountTierA({ + credentials: fakeCredentials({ refs: { OPENCODE_API_KEY: "k" } }), + plugin: {}, + providers: { + deepseek: { + api: "openai-responses", + apiKeyEnv: "DEEPSEEK_API_KEY", + baseURL: "http://127.0.0.1:9/v1", + headers: { "x-sib": "1" }, + reasoning: "high", + retryPolicy: { maxRetries: 1, mode: "normal" }, + }, + opencode: { + apiKeyEnv: "OPENCODE_API_KEY", + models: [{ id: "deepseek-v4-pro" }], + }, + }, + }); + await waitForPlaneOwner(tierA.llm, ["opencode"]); + const { requests } = await turn(tierA.llm, "opencode", "deepseek-v4-pro"); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + "https://opencode.ai/inference/openai/v1/chat/completions" + ); + expect(requests[0]?.headers).not.toHaveProperty("x-sib"); + expect(adaptersOf(tierA.llm).get("opencode")?.retryPolicy).toEqual( + resolveRetryPolicy(undefined, "t") + ); + const profile = planeProfile(tierA.llm, "opencode"); + expect(profile.headers).toEqual({}); + expect(profile.reasoning).toBeUndefined(); + }); +}); + +describe( + "host planes · the declared listing (tier B)", + { timeout: 30_000 }, + () => { + it("lists every declared id on both planes, keyless or not", async () => { + offline(); + tierB = await bootTierB({ providers: characterizedProviders() }); + await waitForPlaneOwner(tierB.llm); + for (const plane of PLANES) { + expect(modelIds(await tierB.llm.listModels(plane))).toEqual( + CHARACTERIZED_MODELS[plane] + ); + } + }); + } +); diff --git a/test/host-shapes.test.ts b/test/host-shapes.test.ts index 30ee2be1..63a1af13 100644 --- a/test/host-shapes.test.ts +++ b/test/host-shapes.test.ts @@ -25,10 +25,17 @@ import { afterEach, describe, expect, it } from "vitest"; import { adaptersOf, + hostModulesForTests, mountTierA, 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 { @@ -459,3 +466,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..097839fe 100644 --- a/test/keyless-matrix.test.ts +++ b/test/keyless-matrix.test.ts @@ -22,14 +22,13 @@ import { 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 +248,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 +302,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); } }); 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/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/plane-dispatch.mock.ts b/test/mock/plane-dispatch.mock.ts new file mode 100644 index 00000000..2b0f280e --- /dev/null +++ b/test/mock/plane-dispatch.mock.ts @@ -0,0 +1,514 @@ +/** + * 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 { + 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 } 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-contributor": "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 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; + 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; + if (protocol !== "google-generative-ai") { + expect((request?.body as { model?: string })?.model).toBe(model); + } + 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; + await turn(llm, plane, model); + expect( + gateway.requests.slice(before).map((request) => request.path), + `${plane}/${model}` + ).toEqual([LEGACY_PATH(plane, protocol, model)]); + } + } + ); + + 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; + await turn(llm, "opencode", model); + expect( + gateway.requests.slice(before).map((request) => request.path) + ).toEqual([LEGACY_PATH("opencode", protocol, model)]); + } + }); + + 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 · 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-contributor" }], + }, + }, + }); + await waitForPlaneOwner(tier.llm, ["opencode-go"]); + const model = "gemini-4-argon-contributor"; + 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(); + }); +}); 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..6851959d --- /dev/null +++ b/test/model-metadata.test.ts @@ -0,0 +1,214 @@ +/** + * 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, +} 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 an effort list exactly, never Off for none/off, 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 + ); + }); +}); diff --git a/test/models-discovery.test.ts b/test/models-discovery.test.ts index 4fb11ff3..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,1562 +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-contributor", "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..e3018ffe --- /dev/null +++ b/test/plane-model.test.ts @@ -0,0 +1,609 @@ +/** + * `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-contributor"); + 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-contributor", 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"); + 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..0cfcdae3 --- /dev/null +++ b/test/plane-profile.test.ts @@ -0,0 +1,448 @@ +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 · 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..d9155831 --- /dev/null +++ b/test/plane-provider.test.ts @@ -0,0 +1,638 @@ +/** + * 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); + } + } + }); +}); 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/reload.test.ts b/test/reload.test.ts index 4d8416c6..3747ea45 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. @@ -32,23 +36,23 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { adaptersOf, mountTierA, + 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"; @@ -62,9 +66,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. @@ -109,30 +110,6 @@ 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; - -/** 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; @@ -175,61 +152,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"), - }, - targetOf: (llm) => llm, - }, { decorated: mergeDiscoveredModels([{ id: "minimax-m3" }], "go"), host: (shape) => @@ -257,89 +243,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") - ), - }, - 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"), }, - targetOf: adapterOf, + targetOf: (settings) => settings, }, ]; @@ -539,23 +463,21 @@ 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; 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) => { + SITES.flatMap((site) => + site.name === "discovery" + ? [ + [site.name, "first disposed first", site, true] as const, + [site.name, "last disposed first", site, false] as const, + ] + : [] + ) + )("%s · %s", async (_siteName, _order, site, 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 ?? {}; - } + const target: object = llm; const listed = llm.listProviders(); const info = await llm.resolveModelInfo("opencode", "deepseek-v4-pro"); for (const key of site.keys) { @@ -580,13 +502,6 @@ describe("reload · on the real host (tier A)", { timeout: 15_000 }, () => { `survivor intact: ${key}` ).toBe(survivor.get(key)); } - if (name === "plane-adapters") { - expect( - baseUrlOf( - await call(target, "resolveModel", "opencode", "deepseek-v4-pro") - ) - ).toBe(planeBase("opencode", "deepseek-v4-pro")); - } (fifo ? b : a)?.(); for (const key of site.keys) { @@ -599,104 +514,140 @@ 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(); + } + }, + }; + }; - apply(ctxFor()); - apply(ctxFor()); - expect(cleanups).toHaveLength(2); + const a = activation(); + const b = activation(); + void apply(a.ctx); + void apply(b.ctx); 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 5bfc6820..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-contributor", 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..b3b711ba --- /dev/null +++ b/test/route-claim.test.ts @@ -0,0 +1,908 @@ +/** + * 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 · 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"]); + // 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 9f486934..c442f0d5 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,36 @@ 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 { 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 { 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 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 +560,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 +629,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 +662,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-contributor": { - 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-contributor: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,35 +717,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-contributor validates — - * measured in `test/host-characterization.test.ts`. 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-contributor": { - ...S0_TODAY.rows["opencode-go/gemini-4-argon-contributor"], - callConfig: "ok", - }, - }; + return S0_TODAY.rows; }; /** @@ -744,20 +747,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 @@ -861,9 +850,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. @@ -894,6 +883,162 @@ 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], + }; +}; + +/** + * 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"], + 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[]; @@ -901,6 +1046,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; } /** @@ -914,21 +1061,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") { @@ -936,13 +1089,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 }; }; // --------------------------------------------------------------------------- @@ -958,18 +1112,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. */ @@ -994,6 +1153,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); @@ -1014,7 +1179,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, @@ -1022,7 +1191,11 @@ export const mountTierA = async (options: TierAOptions): Promise => { pluginFiber, }; } catch (error) { - await ctx.fiber.dispose(); + try { + await ctx.fiber.dispose(); + } finally { + restoreSource(); + } throw error; } }; @@ -1201,8 +1374,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. @@ -1414,3 +1587,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..425369c7 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; diff --git a/test/support/src.ts b/test/support/src.ts index e38648ab..b6776e77 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,35 @@ 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/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 +186,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 +202,7 @@ export { isGoModelsListingUrl, isModelsListingUrl, MODELS_OPENCODE_URL, + onCatalogChange, parseModelsDevCatalog, refreshCatalog, resetCatalogState, @@ -185,15 +212,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 +233,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 +242,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 +384,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 +395,58 @@ export { isUnknownArray, } from "../../src/utils/guards.ts"; +// utils/model-metadata.ts +export { + canDisableReasoning, + effortName, + PI_LEVELS, + piReasoningFromEfforts, + reasoningFor, +} 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..b5f1c677 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,89 @@ 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, + }, + "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, + }, }, }, }, From 9cf8372e539f86f41e36b892f0fda0e718cce3cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:10:10 +0000 Subject: [PATCH 02/20] test: follow the vendor catalog refresh The refreshed Go catalog serves its Gemini model as gemini-4-argon, so the plane-dispatch mock, the plane-model cases and the binary e2e's probe expectations name the new id; the Go Google base they assert is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- test/e2e/host-binary.e2e.ts | 10 +++------- test/mock/plane-dispatch.mock.ts | 6 +++--- test/plane-model.test.ts | 5 ++--- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/test/e2e/host-binary.e2e.ts b/test/e2e/host-binary.e2e.ts index 2e515e48..cd2cd253 100644 --- a/test/e2e/host-binary.e2e.ts +++ b/test/e2e/host-binary.e2e.ts @@ -477,14 +477,10 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { sent: row.sent, }); } - expect( - out.dispatch["opencode-go/gemini-4-argon-contributor"]?.sent - ).toEqual([ - "https://opencode.ai/inference/go/google/v1beta/models/gemini-4-argon-contributor:streamGenerateContent?alt=sse", + 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-contributor"]).toBe( - "ok" - ); + expect(out.callConfig["opencode-go/gemini-4-argon"]).toBe("ok"); } ); diff --git a/test/mock/plane-dispatch.mock.ts b/test/mock/plane-dispatch.mock.ts index 2b0f280e..c47fecae 100644 --- a/test/mock/plane-dispatch.mock.ts +++ b/test/mock/plane-dispatch.mock.ts @@ -62,7 +62,7 @@ const PROTOCOL_OF: Readonly>>> = }, "opencode-go": { "deepseek-v4-pro": "openai-completions", - "gemini-4-argon-contributor": "google-generative-ai", + "gemini-4-argon": "google-generative-ai", "minimax-m3": "anthropic-messages", "minimax-m3.1-flash": "openai-completions", "muse-spark-1.2-contributor": "openai-responses", @@ -417,12 +417,12 @@ describe("plane dispatch · Go Gemini replay stays on one provider label", () => providers: { "opencode-go": { apiKeyEnv: "OPENCODE_GO_API_KEY", - models: [{ id: "gemini-4-argon-contributor" }], + models: [{ id: "gemini-4-argon" }], }, }, }); await waitForPlaneOwner(tier.llm, ["opencode-go"]); - const model = "gemini-4-argon-contributor"; + const model = "gemini-4-argon"; const tools = [ { description: "Read a sample value", diff --git a/test/plane-model.test.ts b/test/plane-model.test.ts index e3018ffe..f90a2d81 100644 --- a/test/plane-model.test.ts +++ b/test/plane-model.test.ts @@ -76,7 +76,7 @@ describe("plane-model · protocol (D16)", () => { expect(modelOf(inputFor("opencode", "mistral-large-4")).api).toBe( "openai-completions" ); - const gemini = inputFor("opencode-go", "gemini-4-argon-contributor"); + 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( @@ -199,8 +199,7 @@ describe("plane-model · base (modern, legacy, route-level)", () => { modelOf(inputFor("opencode", "gpt-5.3-codex-spark", legacy)).baseUrl ).toBe("https://opencode.ai/zen/v1"); expect( - modelOf(inputFor("opencode-go", "gemini-4-argon-contributor", legacy)) - .baseUrl + 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 From 0cceb81ffc87c7e013c5a9d8770d25373a47cf66 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:05:14 +0000 Subject: [PATCH 03/20] fix: address the second review of P-R1 The review ran three rounds (found 20, 5 and 8; upheld 19, 4 and 8). What it found and what changed: - A completions model whose effort list names none lost its Off. utils/model-metadata.ts now offers Off with none as its wire value on completions, as pi 1.1.0's own entries do; Responses unchanged. Pinned in model-metadata, plane-model and a new mock-gateway case. - A Go entry with no apiKeyEnv authenticates with OPENCODE_API_KEY (pi's lookup, D20), and nothing said so. README "Changed in 1.x" and step 3 now name it; host-planes.test.ts pins both cases. - A Settings save of a plane was refused as "cannot be validated yet" while the host modules were unloaded. host/route-claim.ts now judges it with the last modules that loaded; route-claim.test.ts pins it. - Fiber.update(config) with noSave false would persist the stripped config and drop both planes. Recorded as a constraint in route-claim.ts and the plan; no caller does it today. - Stale text: cordis-context.ts comments (stream redirect, isolate, root inject), deep-dive.md (pi's Go lookup, who answers Fetch), CONTRIBUTING (field count, the host-modules floor), and historical banners on opencode-parity-plan.md and upstream-findings.md. - The mock body-shape row asserted only body.model. It now checks each protocol's required fields and that the gateway answered 200, or the vendor's 404 for the unknown id. - Route-set rows: removing the last plane now pins replace([]) on the live handle, and "booting with none" waits for the plugin's row to be active instead of sleeping 50 ms. - The live protocol-routing suite now sends paid models through the plane adapter on a tier-A host, not only raw probes. - README's credential-isolation claim was false: the Go meter can send the Zen key. README.md, README.zh-CN.md, deep-dive.md, credentials.md and redirect-planes.md now give the real order and the 403 remedies; readme-parity.test.ts keeps both READMEs in step. - The keySource hint described Automatic backwards. settings-copy.ts (en and zh) now gives literal key, captured key, then reference. - The live adapter blocks dropped a protocol whose model was withdrawn. They now walk up to three candidates per protocol (support/live-walk.ts), pass a "model is unavailable" turn only when a raw probe confirms it (support/live-probe.ts), and skip naming a protocol no candidate reached. - A mock comment claimed a body passed shape checks the mock lacks. Declined: a toggle beside an effort list still offers no Off, which moves baseline facets and needs the owner's decision; the Models store's join stays a documented restatement rather than importing a client UI package; the PR description is edited outside the tree. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- AGENTS.md | 1 + CONTRIBUTING.md | 8 +- README.md | 11 +- README.zh-CN.md | 13 +- docs/credentials.md | 4 +- docs/deep-dive.md | 8 +- docs/improvement-plan.md | 2 + docs/opencode-parity-plan.md | 2 + docs/redirect-planes.md | 4 +- docs/upstream-findings.md | 4 + src/cordis-context.ts | 23 +- src/host/route-claim.ts | 27 ++- src/settings-copy.ts | 4 +- src/utils/model-metadata.ts | 54 +++-- test/e2e/dispatch-levels.e2e.ts | 170 ++++++++++----- test/e2e/protocol-routing.e2e.ts | 358 +++++++++++++++++-------------- test/host-planes.test.ts | 89 +++++++- test/keyless-matrix.test.ts | 16 ++ test/live-walk.test.ts | 120 +++++++++++ test/mock/plane-dispatch.mock.ts | 155 ++++++++++++- test/model-metadata.test.ts | 51 ++++- test/plane-model.test.ts | 14 ++ test/readme-parity.test.ts | 100 +++++++++ test/route-claim.test.ts | 48 +++++ test/support/keyless-matrix.ts | 18 ++ test/support/live-probe.ts | 206 ++++++++++++++++++ test/support/live-walk.ts | 107 +++++++++ 27 files changed, 1330 insertions(+), 287 deletions(-) create mode 100644 test/live-walk.test.ts create mode 100644 test/readme-parity.test.ts create mode 100644 test/support/live-probe.ts create mode 100644 test/support/live-walk.ts diff --git a/AGENTS.md b/AGENTS.md index 00e3cb73..a508a882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: - `test/support/host-versions.ts` — the host the test install runs (pi, the `@deepseek-ai` cluster, cordis and its loader), named once; `test/host-versions.test.ts` checks the installed tree against it (with `pnpm-lock.yaml` as the record of what pnpm resolved, and the published peer ranges) and `test/harness-channel.test.ts` checks the declarations and the lockfile, including that `@deepseek-ai/schemastery` resolves to one copy inside the cluster's range. - `test/support/host.ts` — the real host, in-process, in two tiers: **A** (`mountTierA`: a bare cordis `Context`, `LlmRuntime` and `dsh-llm-pi-ai` mounted with `{ providers }`) for the `internal/config` waterfall and pi-ai's credential paths, and **B** (`bootTierB`: dsh-app-boot's `boot()` with a temporary HOME and a test bundle) for the config editor, `settings.describe`, the volatile commit and the characterization of the plugin as it ships. Its fakes (`fakeCredentials`, `fakeLaunchEnvironment`, `recordingFetch`) keep every case offline and its credentials out of the environment (a boot blanks the two keys the plugin reads directly, should the shell export them, and restores them on dispose); `S0_PROFILE` is the contract fixture, `CHARACTERIZED_PROFILE` (S0 plus a declared catalog-unknown id) the characterization's, and `S0_TODAY` the measured per-model rows, tagged per facet, that the binary e2e shares (it reads `S0_TODAY.rows` directly; `todayRowsFor`, which only the characterization calls, answers the same rows for either apply order since the route claim). `logLines` captures every logger line at every level, because cordis's own buffer stops at `info` and tier B prints nothing to the console. Tier A has no loader, so the plugin's default host-module source cannot find pi-ai there; the harness swaps in `hostModulesForTests` through the seam in `src/cordis-context.ts`, and the route claim serves the planes in tier A as it does on the real host; `test/host-harness.test.ts` exercises the builder options no contract or characterization case passes, and tier B's two failure paths (an invalid first row, an exported key). Tier B's rows carry PACKAGE names through a stub module loader, never `cordis:` builtins, because the plugin's default host-module source finds pi-ai's entry by `options.name` (`piAiEntry` in `src/host/host-modules.ts`) and a builtin-named row would leave the route claim without pi-ai's module. It also fixes which of pi-ai and the plugin applies first (`order`), because the loader starts rows in parallel and the published binary applies the plugin first; the held row is released when the first one applies or its fiber settles, so an invalid first row fails instead of hanging the boot. - `test/support/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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a43d73b9..2c65f108 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,8 +43,8 @@ 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`: 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. -- `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, and skips without the plane's key or when no toggle model was entitled on the account. +- `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`. @@ -98,7 +98,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 @@ -127,7 +127,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Raise them when you genuinely add coverage** — a PR that lifts statements by a few points should lift the threshold with it, or the next contributor inherits a gate nobody re-measured. - **Never set one above the current measurement.** That does not make the suite better; it makes every subsequent PR fail until someone deletes tests. -`src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. Load-bearing modules carry their own per-file floors, so a well-covered average cannot hide one that stopped being exercised: `host/patch-method.ts` and the four pure plane modules (`utils/protocols.ts`, `utils/model-metadata.ts`, `utils/plane-model.ts`, `utils/plane-profile.ts`) at 100%, because each branch is a reload case, a protocol, a precedence or one of pi-ai's checks; `host/route-claim.ts`, `host/plane-provider.ts` and `host/describe-graft.ts` just below what they measure. `test/architecture.test.ts` fails the build on a floor whose file no longer exists, which would otherwise be a floor on nothing. +`src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. Load-bearing modules carry their own per-file floors, so a well-covered average cannot hide one that stopped being exercised: `host/patch-method.ts` and the four pure plane modules (`utils/protocols.ts`, `utils/model-metadata.ts`, `utils/plane-model.ts`, `utils/plane-profile.ts`) at 100%, because each branch is a reload case, a protocol, a precedence or one of pi-ai's checks; `host/route-claim.ts`, `host/plane-provider.ts`, `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. ## Release process (maintainers) diff --git a/README.md b/README.md index e64a8454..25df85fd 100644 --- a/README.md +++ b/README.md @@ -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** @@ -105,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. @@ -123,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 | @@ -172,8 +172,8 @@ Four properties follow: - **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. -- **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. 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. +- **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. **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. @@ -417,6 +417,7 @@ Disabling or removing the plugin hands both planes back to `llm-pi-ai`, with not - **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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 186ea41f..089bda8c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 平面请求携带的密钥 **目录** @@ -103,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`)只需刷新浏览器。 @@ -121,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`):显示名、上下文窗口与价格 | @@ -170,8 +170,8 @@ Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在 - **你的挑选仍然有效。** 每个平面服务的正是其 `llm-pi-ai` 条目列出的模型;条目没有列出任何模型时,服务整个目录。你保存的任何 id 都会被接受并服务:目录与 pi 都未描述的 id 按 Chat Completions 发送,除非平面条目指定了路由级的 `api`——它只作用于这类 id。 - **你的模型行优先。** 模型的名称、上下文窗口、输出上限与输入模态,先取你自己的模型行,再取目录,最后取 pi 的内置数据。推理档位先取你行里的 `reasoningEfforts`,再取目录。价格与协议不是模型行的字段:价格先取目录、再取 pi,协议先取目录的 SDK、再取 pi 的条目。 -- **只有开/关两种推理的模型提供“关 / 开”。** 只有该模型的协议确实能关闭推理时才提供“关”;有真正档位列表的模型只提供它自己的档位。 -- **一份凭据,按宿主自己的规则。** 平面的认证方式与 `llm-pi-ai` 相同:条目的 `apiKeyEnv` 经凭据服务解析(未挂载凭据服务时读启动环境),保存在 `llm-pi-ai/opencode` 下的登录记录,否则用 pi 自己的 `OPENCODE_API_KEY` 查找。只有在没有声明引用、且部署选择开启时,才会读取 OpenCode 安装自己的存储。密钥解析不到的平面仍列出其模型,回合在发出任何请求前以 `MISSING_CREDENTIAL` 失败。 +- **只有开/关两种推理的模型提供“关 / 开”。** 只有该模型的协议确实能关闭推理时才提供“关”;有真正档位列表的模型只提供它自己的档位;档位列表中含 `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` 的 `providers` 块里,_Settings → Models_ 也在那里读写它们。插件把去掉这两个平面的配置副本交给 `llm-pi-ai`,让它不服务这两个平面;并用 `llm-pi-ai` 自己的检查校验对平面的设置编辑,所以格式错误的条目会在写入前被拒绝。 @@ -393,7 +393,7 @@ 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` 路由 | +| 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 模型行为异常 | 与本补丁无关 | 发往其他网关的流量原样通过 | @@ -407,6 +407,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 - **插件自己服务 `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`。仍带有这些已移除键的配置行可以通过校验,这些键会被忽略。 diff --git a/docs/credentials.md b/docs/credentials.md index 9454f899..03bbe535 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -13,6 +13,8 @@ The plugin's default is the deployment's own credential: whatever `apiKeyEnv` th 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`. **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: @@ -44,7 +46,7 @@ Where a route names no reference at all, the chain continues into the login and ## 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. diff --git a/docs/deep-dive.md b/docs/deep-dive.md index b1591af5..e063e6b5 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: @@ -171,7 +171,7 @@ 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 fails when the shim is stale. 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 a plane route the plugin's plane adapter answers the way `llm-pi-ai` does, from pi's 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, 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. +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. (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 `OPENCODE_API_KEY`/`OPENCODE_GO_API_KEY` lookup. `keySource` orders only what the meter and the request patch read.) 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 3d51489e..4c1234f2 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -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. @@ -390,6 +391,7 @@ What stays private is pinned by contract tests against the real host packages: - **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.** 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. 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/redirect-planes.md b/docs/redirect-planes.md index 74bf6aa8..925770fb 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` | 82 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` | 82 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. 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/src/cordis-context.ts b/src/cordis-context.ts index 44ddeb29..7194505d 100644 --- a/src/cordis-context.ts +++ b/src/cordis-context.ts @@ -85,18 +85,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[], @@ -120,12 +117,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 @@ -288,8 +279,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`. diff --git a/src/host/route-claim.ts b/src/host/route-claim.ts index 86728a70..8cef75aa 100644 --- a/src/host/route-claim.ts +++ b/src/host/route-claim.ts @@ -19,6 +19,18 @@ * 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 @@ -193,6 +205,14 @@ export const installRouteClaim = ( 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< @@ -553,6 +573,7 @@ export const installRouteClaim = ( return; } state.modules = modules; + state.validationModules = modules; try { rebuild(pi, modules); } catch (error) { @@ -591,7 +612,10 @@ export const installRouteClaim = ( ) { continue; } - const { modules } = state; + // 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` @@ -783,6 +807,7 @@ export const installRouteClaim = ( 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) { diff --git a/src/settings-copy.ts b/src/settings-copy.ts index adfc8f84..e63d8007 100644 --- a/src/settings-copy.ts +++ b/src/settings-copy.ts @@ -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: @@ -211,7 +211,7 @@ export const zh: Record = { keySourceAuto: "自动", keySourceConfigured: "优先已声明密钥", keySourceHint: - "多个来源都可用时以哪个为准。自动先取已声明密钥、再取捕获密钥;另两项各优先一侧。", + "多个来源都可用时以哪个为准。自动先取字面密钥,再取实时请求捕获的密钥,最后取声明的引用;优先已声明密钥把引用排在捕获密钥之前,优先实时请求把捕获密钥排在最前。", keySourceRequest: "优先实时请求", readOpencodeStore: "读取 OpenCode 安装的密钥(默认关闭)", readOpencodeStoreHint: diff --git a/src/utils/model-metadata.ts b/src/utils/model-metadata.ts index 93610897..be27d3c1 100644 --- a/src/utils/model-metadata.ts +++ b/src/utils/model-metadata.ts @@ -10,6 +10,9 @@ * - 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 @@ -95,10 +98,16 @@ export interface ModelReasoning { readonly effortNames: Readonly>; } -/** The levels a catalog spec offers, and whether it is a bare switch. */ +/** 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 } => { +): { levels: PiLevel[]; toggle: boolean; disable?: string } => { const options = spec.reasoning_options ?? []; const values = options.find( (option) => option.type === "effort" && (option.values?.length ?? 0) > 0 @@ -108,7 +117,12 @@ const offeredLevels = ( (level) => level !== "off" && values.includes(level) ); if (levels.length > 0) { - return { levels, toggle: false }; + const disable = DISABLE_VALUES.find((value) => values.includes(value)); + return { + levels, + toggle: false, + ...(disable === undefined ? {} : { disable }), + }; } } if (options.some((option) => option.type === "toggle")) { @@ -120,8 +134,17 @@ const offeredLevels = ( /** * The reasoning a catalog spec declares, for one protocol. * - * - An `effort` list offers exactly its pi levels. `none`/`off` in it never - * offer Off: those models do not take an explicit disable. + * - 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`. @@ -139,22 +162,27 @@ export const reasoningFor = ( if (spec.reasoning !== true) { return undefined; } - const { levels, toggle } = offeredLevels(spec); + 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: null }; + : { off: offWire ?? null }; for (const level of PI_LEVELS) { if (level !== "off") { thinkingLevelMap[level] = levels.includes(level) ? level : null; } } - const reasoningEfforts: Record = off - ? { off: null } - : {}; - const efforts: { id: string; name: string }[] = off - ? [{ id: "off", name: "Off" }] - : []; + // 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) }); diff --git a/test/e2e/dispatch-levels.e2e.ts b/test/e2e/dispatch-levels.e2e.ts index 8ba6047b..093d2d3c 100644 --- a/test/e2e/dispatch-levels.e2e.ts +++ b/test/e2e/dispatch-levels.e2e.ts @@ -22,9 +22,12 @@ * 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. It skips - * without the plane's key, and when no toggle model was entitled on the - * account (none reached the gateway's body check). + * 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. @@ -41,6 +44,18 @@ import { 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, @@ -179,23 +194,32 @@ describe.skipIf(!ENABLED)( } ); -/** The first toggle-only model of each protocol on a plane: its efforts are named "On". */ +/** The most toggle-only candidates any one protocol declares in the live block. */ +const TOGGLE_CANDIDATES = 3; + +/** One turn's own timeout in the live block. */ +const TURN_TIMEOUT_MS = 60_000; + +/** + * 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] -): { spec: CatalogModelSpec; protocol: Protocol }[] => { - const seen = new Map(); - for (const spec of plane.catalog) { - const protocol = protocolForSdk(spec.provider_npm); - if (protocol === undefined || seen.has(protocol)) { - continue; - } - const reasoning = reasoningFor(spec, protocol); - if (reasoning?.efforts.some((effort) => effort.name === "On") === true) { - seen.set(protocol, spec); - } - } - return [...seen].map(([protocol, spec]) => ({ protocol, spec })); -}; +): 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 = @@ -209,14 +233,17 @@ describe.skipIf(!ENABLED)( it.for(PLANES)( "$route: every toggle-only protocol accepts On through the plane adapter", - { timeout: 120_000 }, + { 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.length).toBeGreaterThan(0); + expect(toggles.size).toBeGreaterThan(0); + const specs = new Map( + [...toggles.values()].flat().map((spec) => [spec.id, spec]) + ); const ref = plane.route === "opencode" ? "OPENCODE_API_KEY" @@ -227,48 +254,83 @@ describe.skipIf(!ENABLED)( providers: { [plane.route]: { apiKeyEnv: ref, - models: toggles.map(({ spec }) => ({ id: spec.id })), + models: [...specs.keys()].map((id) => ({ id })), }, }, }); try { await waitForPlaneOwner(tier.llm, [plane.route]); - const refused: string[] = []; - let reached = 0; - for (const { protocol, spec } of toggles) { - // The id the picker's "On" carries, read from what the adapter - // answers rather than assumed. - const info = (await tier.llm.resolveModelInfo( - plane.route, - spec.id - )) as { reasoning?: { efforts?: { id: string; name: string }[] } }; - const on = info.reasoning?.efforts?.find( - (effort) => effort.name === "On" - )?.id; - expect(on, `${spec.id} offers no On`).toBeDefined(); - const drained = await drainStream(tier.llm, plane.route, spec.id, { - reasoningEffort: on, - signal: AbortSignal.timeout(60_000), - }); - const message = drained.failureMessage ?? drained.error ?? ""; - if (ENTITLEMENT.test(message)) { - continue; - } - reached += 1; - if ( - drained.error !== undefined || - drained.finish?.kind === "error" - ) { - refused.push(`${spec.id} (${protocol}): ${message}`); + 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" }; } - } - // Every toggle model refused on entitlement: nothing about the wire - // was observed, which is a skip, never a pass. + ); + 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( - reached === 0, - "no toggle model was entitled on this account" + walk.unreached.length > 0, + `unreached (every toggle candidate refused on entitlement or withdrawn): ${walk.unreached.join(", ")}` ); - expect(refused).toEqual([]); } finally { await tier.dispose(); } diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index 23cb0220..15ee859a 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -53,6 +53,22 @@ * 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: an override is used for every protocol with the * legacy family's paths, as before. @@ -62,13 +78,33 @@ import { describe, expect, it } from "vitest"; +import { + 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, @@ -112,89 +148,6 @@ const FANOUT_BUDGET_MS = 240_000; /** What a model with no SDK of its own is served on (D16). */ const DEFAULT_PROTOCOL = "openai-completions"; -/** - * The path each protocol uses against an OVERRIDDEN base, which is read as a - * legacy-family base (`…/zen/v1`): one base for every protocol. - */ -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. - */ -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. - */ -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; -} - /** * 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 @@ -235,70 +188,18 @@ const probe = async ( model: string, key?: string, plane: "go" | "zen" = "zen" -): Promise => { - 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(urlFor(plane, protocol, model), { - 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) - }`, - }; - } - const body = await response.text(); - return { - status: response.status, - summary: `${response.status} ${body.slice(0, 200)}`, - }; -}; +): 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. * @@ -323,29 +224,6 @@ 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" }, @@ -999,3 +877,149 @@ 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); +}; + +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 planeName = plane.route === "opencode" ? "zen" : "go"; + 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, + async (protocol, id): Promise => { + const drained = await drainStream(tier.llm, plane.route, id, { + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + 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 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" }; + } + ); + 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(); + } + } + ); + } +); diff --git a/test/host-planes.test.ts b/test/host-planes.test.ts index 9f588ab1..2aa1f0fd 100644 --- a/test/host-planes.test.ts +++ b/test/host-planes.test.ts @@ -51,9 +51,11 @@ import { apply, clearCapturedApiKeys, Config, + getCapturedApiKey, inject, name, recordCapturedApiKey, + resolveGoApiKey, } from "./support/src.ts"; /** The ids of a listing, in order. */ @@ -591,9 +593,15 @@ describe("host planes · the route set (tier B)", { timeout: 30_000 }, () => { const { tier } = await bootPlanes({ providers: { deepseek: deepseek ?? {} }, }); - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); + // 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( @@ -643,6 +651,81 @@ describe("host planes · credentials (tier A, D20)", { timeout: 30_000 }, () => 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" } }, diff --git a/test/keyless-matrix.test.ts b/test/keyless-matrix.test.ts index 097839fe..e409fecb 100644 --- a/test/keyless-matrix.test.ts +++ b/test/keyless-matrix.test.ts @@ -17,6 +17,7 @@ import { cellKey, classifyKeylessProbe, isFreeSpec, + isModelGoneMessage, isTransientProbeAnswer, keylessMatrix, MATRIX_PROTOCOLS, @@ -335,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/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/plane-dispatch.mock.ts b/test/mock/plane-dispatch.mock.ts index c47fecae..3f1e9f4f 100644 --- a/test/mock/plane-dispatch.mock.ts +++ b/test/mock/plane-dispatch.mock.ts @@ -137,6 +137,57 @@ const urlOf = (input: RequestInfo | URL): string => { 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"; @@ -231,15 +282,25 @@ describe("plane dispatch · the default (modern) family", () => { for (const model of CHARACTERIZED_MODELS[plane]) { const protocol = protocolOf(plane, model); const before = gateway.requests.length; - await turn(llm, plane, model); + 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; - if (protocol !== "google-generative-ai") { - expect((request?.body as { model?: string })?.model).toBe(model); + 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( @@ -261,11 +322,13 @@ describe("plane dispatch · the legacy family and a route-level base", () => { for (const model of CHARACTERIZED_MODELS[plane]) { const protocol = protocolOf(plane, model); const before = gateway.requests.length; - await turn(llm, plane, model); + const chunks = await turn(llm, plane, model); + const sent = gateway.requests.slice(before); expect( - gateway.requests.slice(before).map((request) => request.path), + sent.map((request) => request.path), `${plane}/${model}` ).toEqual([LEGACY_PATH(plane, protocol, model)]); + expectAnswered(`${plane}/${model}`, model, sent[0], chunks); } } ); @@ -277,10 +340,12 @@ describe("plane dispatch · the legacy family and a route-level base", () => { for (const model of ["deepseek-v4-pro", "gpt-5.4", "claude-haiku-5-5"]) { const protocol = protocolOf("opencode", model); const before = gateway.requests.length; - await turn(llm, "opencode", model); - expect( - gateway.requests.slice(before).map((request) => request.path) - ).toEqual([LEGACY_PATH("opencode", protocol, model)]); + 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); } }); @@ -363,6 +428,78 @@ describe("plane dispatch · the toggle model's Off and On (D1)", () => { ); }); +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 diff --git a/test/model-metadata.test.ts b/test/model-metadata.test.ts index 6851959d..41d1ca6c 100644 --- a/test/model-metadata.test.ts +++ b/test/model-metadata.test.ts @@ -74,7 +74,56 @@ describe("model-metadata · reasoningFor (D1)", () => { expect(ids(deepseek)).toEqual(["off", "high"]); }); - it("offers an effort list exactly, never Off for none/off, and low/medium/high for a bare reasoning flag", () => { + 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"]); diff --git a/test/plane-model.test.ts b/test/plane-model.test.ts index f90a2d81..eeb3660b 100644 --- a/test/plane-model.test.ts +++ b/test/plane-model.test.ts @@ -510,6 +510,20 @@ describe("plane-model · metadata (D17) and the model object", () => { 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" } }, diff --git a/test/readme-parity.test.ts b/test/readme-parity.test.ts new file mode 100644 index 00000000..b98c2b04 --- /dev/null +++ b/test/readme-parity.test.ts @@ -0,0 +1,100 @@ +/** + * 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, 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("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/route-claim.test.ts b/test/route-claim.test.ts index b3b711ba..617c126c 100644 --- a/test/route-claim.test.ts +++ b/test/route-claim.test.ts @@ -410,6 +410,39 @@ describe("route-claim · the listener", () => { }); }); +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(); @@ -671,6 +704,21 @@ describe("route-claim · reconcile", () => { 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")?.(); diff --git a/test/support/keyless-matrix.ts b/test/support/keyless-matrix.ts index 425369c7..1bd25a67 100644 --- a/test/support/keyless-matrix.ts +++ b/test/support/keyless-matrix.ts @@ -253,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 }; +}; From 4dbd22e20458aea12613ce489c77cba07ee63b7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:34:59 +0000 Subject: [PATCH 04/20] fix: send Google's thinking level to Gemini models the catalog gives levels The live suite caught the Go plane's Gemini model refusing a thinking budget: the gateway answered that the model takes a thinking level and not a budget. Pi chooses a thinking level over a thinking budget only by matching the model id against its own pattern, so a model the pattern does not name was sent a budget it cannot accept. The plugin now makes that decision from the catalog, as D16 asks: a model whose catalog entry offers discrete effort levels on the Google protocol gets a thinking level. It builds that request through pi's public exports, on both the enabled and the disabled path, and hands every other model back to pi's own path unchanged. Tests pin it: the pure rule over the catalog, a differential check that the wrapper builds the same request as pi's own streamSimple for an id pi already recognises, the lazy loading of the pi pieces, and a mock-gateway case that the turn completes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- docs/protocol-routing-and-merge.md | 2 +- docs/redirect-planes.md | 3 +- src/cordis-context.ts | 7 + src/host/google-levels.ts | 215 ++++++++++++ src/host/host-modules.ts | 62 ++++ src/host/plane-provider.ts | 25 +- src/utils/model-metadata.ts | 55 ++- src/utils/plane-profile.ts | 35 +- test/e2e/host-binary.e2e.ts | 14 + test/e2e/host/probe-plugin.mjs | 41 ++- test/e2e/protocol-routing.e2e.ts | 171 +++++++-- test/google-levels.test.ts | 535 +++++++++++++++++++++++++++++ test/host-modules.test.ts | 119 +++++++ test/mock/opencode-gateway.ts | 30 ++ test/mock/plane-dispatch.mock.ts | 209 ++++++++++- test/model-metadata.test.ts | 142 ++++++++ test/plane-profile.test.ts | 107 ++++++ test/plane-provider.test.ts | 70 ++++ test/support/host.ts | 33 +- test/support/src.ts | 8 + vite.config.ts | 8 + 23 files changed, 1833 insertions(+), 62 deletions(-) create mode 100644 src/host/google-levels.ts create mode 100644 test/google-levels.test.ts diff --git a/AGENTS.md b/AGENTS.md index a508a882..a8dab03e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ 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. -- `src/host/route-claim.ts` · `src/host/plane-provider.ts` · `src/host/describe-graft.ts` · `src/host/host-modules.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. +- `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 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`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c65f108..a2d24e72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,7 +127,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Raise them when you genuinely add coverage** — a PR that lifts statements by a few points should lift the threshold with it, or the next contributor inherits a gate nobody re-measured. - **Never set one above the current measurement.** That does not make the suite better; it makes every subsequent PR fail until someone deletes tests. -`src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. Load-bearing modules carry their own per-file floors, so a well-covered average cannot hide one that stopped being exercised: `host/patch-method.ts` and the four pure plane modules (`utils/protocols.ts`, `utils/model-metadata.ts`, `utils/plane-model.ts`, `utils/plane-profile.ts`) at 100%, because each branch is a reload case, a protocol, a precedence or one of pi-ai's checks; `host/route-claim.ts`, `host/plane-provider.ts`, `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. +`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. ## Release process (maintainers) diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index 6dfd1fb0..6f1c6c18 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -136,7 +136,7 @@ What belongs here is only the part that is a routing consequence: the meter is a | Concern | Module | | :-- | :-- | | 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/describe-graft.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 925770fb..58254b3c 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -187,5 +187,6 @@ What carried over from the bridge, and why: - **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. -What is verified, and in what composition: the in-process characterization (`test/host-characterization.test.ts`) and the real-host suite (`test/host-planes.test.ts`) run the plugin on the real `llm` service and `llm-pi-ai` in both apply orders, and the binary e2e (`test/e2e/host-binary.e2e.ts`) runs the built bundle on a published host, asserting that both planes are the plugin's `PlaneAdapter` and that a Go Gemini turn is sent to `https://opencode.ai/inference/go/google/v1beta`. +What is verified, and in what composition: the in-process characterization (`test/host-characterization.test.ts`) and the real-host suite (`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/src/cordis-context.ts b/src/cordis-context.ts index 7194505d..a595994f 100644 --- a/src/cordis-context.ts +++ b/src/cordis-context.ts @@ -12,6 +12,7 @@ */ 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"; @@ -415,6 +416,12 @@ export interface HostLibraries { 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`. */ 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 index e1fcf949..efe0ffcc 100644 --- a/src/host/host-modules.ts +++ b/src/host/host-modules.ts @@ -31,6 +31,7 @@ import type { } 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"; @@ -284,9 +285,13 @@ const PI_MODULES = { 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; @@ -345,6 +350,59 @@ const authOf = ( } }; +/** + * 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. @@ -378,6 +436,7 @@ export const loadLibrariesFrom = async ( go, google, headers, + lazy, models, responses, zen, @@ -390,6 +449,7 @@ export const loadLibrariesFrom = async ( importRecord(piFile("go")), importRecord(piFile("google")), importRecord(piFile("headers")), + importRecord(piFile("lazy")), importRecord(piFile("models")), importRecord(piFile("responses")), importRecord(piFile("zen")), @@ -424,6 +484,7 @@ export const loadLibrariesFrom = async ( 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( @@ -466,6 +527,7 @@ export const loadLibrariesFrom = async ( ...(interpolate === undefined ? {} : { interpolate: interpolate as HostLibraries["interpolate"] }), + ...(googleLevels === undefined ? {} : { googleLevels }), }; /* oxlint-enable typescript/no-unsafe-type-assertion */ }; diff --git a/src/host/plane-provider.ts b/src/host/plane-provider.ts index 53d9b384..d5292a49 100644 --- a/src/host/plane-provider.ts +++ b/src/host/plane-provider.ts @@ -32,6 +32,7 @@ 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 { @@ -544,19 +545,31 @@ export const planeApiKeyResolver = * 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). + * 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, models, name }) => + ({ id, levelThinkingModels = new Set(), models, name }) => libraries.createProvider({ api: Object.fromEntries( - PROTOCOLS.map((protocol) => [ - protocol, - libraries.withOpenCodeSessionHeader(libraries.apis[protocol]()), - ]) + 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" diff --git a/src/utils/model-metadata.ts b/src/utils/model-metadata.ts index be27d3c1..c86bd7af 100644 --- a/src/utils/model-metadata.ts +++ b/src/utils/model-metadata.ts @@ -22,7 +22,7 @@ * @module dsh-opencode-patch/utils/model-metadata */ -import type { Protocol } from "./protocols.ts"; +import { type Protocol, protocolForSdk } from "./protocols.ts"; /** pi's thinking levels, in escalation order, `off` first. */ export const PI_LEVELS = [ @@ -98,6 +98,55 @@ export interface ModelReasoning { 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"]; @@ -109,9 +158,7 @@ const offeredLevels = ( spec: ReasoningSpec ): { levels: PiLevel[]; toggle: boolean; disable?: string } => { const options = spec.reasoning_options ?? []; - const values = options.find( - (option) => option.type === "effort" && (option.values?.length ?? 0) > 0 - )?.values; + const values = effortValuesOf(spec); if (values !== undefined) { const levels = PI_LEVELS.filter( (level) => level !== "off" && values.includes(level) diff --git a/src/utils/plane-profile.ts b/src/utils/plane-profile.ts index e5f8a450..4071f47d 100644 --- a/src/utils/plane-profile.ts +++ b/src/utils/plane-profile.ts @@ -20,6 +20,7 @@ */ import { isRecord } from "./guards.ts"; +import { usesCatalogThinkingLevels } from "./model-metadata.ts"; import { type EndpointChoice, type PlaneCatalogSpec, @@ -92,6 +93,11 @@ 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. */ @@ -266,6 +272,29 @@ const setCompatFields = (compat: unknown): string[] => .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; @@ -368,9 +397,13 @@ export const planeProfileFor = ( let piProvider: unknown; if (routeError === undefined) { try { + const served = models.filter( + (model) => !modelErrors.has(String(model.id)) + ); piProvider = input.buildProvider({ id: route, - models: models.filter((model) => !modelErrors.has(String(model.id))), + levelThinkingModels: levelThinkingModelsOf(served, input.catalog), + models: served, name: displayName, }); } catch (error) { diff --git a/test/e2e/host-binary.e2e.ts b/test/e2e/host-binary.e2e.ts index cd2cd253..f3d96aa8 100644 --- a/test/e2e/host-binary.e2e.ts +++ b/test/e2e/host-binary.e2e.ts @@ -105,6 +105,8 @@ 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, @@ -484,6 +486,18 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { } ); + // 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", () => { expect(out.describe?.value.toSorted()).toEqual([ "deepseek", diff --git a/test/e2e/host/probe-plugin.mjs b/test/e2e/host/probe-plugin.mjs index f5ed3664..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. * @@ -105,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 } @@ -315,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 = {}; @@ -389,6 +413,7 @@ const measure = async (ctx) => { dispatch, modelsJoin, listModelsAfter, + thinking, }; }; diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index 15ee859a..a6c87d9c 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -76,6 +76,7 @@ * 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 { @@ -111,6 +112,7 @@ import { findModelSpecOn, isServableSdk, planeModelFor, + usesCatalogThinkingLevels, } from "../support/src.ts"; const LIVE = process.env.OPENCODE_E2E === "1"; @@ -923,6 +925,71 @@ const paidProtocolOf = ( : (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", () => { @@ -940,7 +1007,6 @@ describe.skipIf(!LIVE)( undefined, "a base override is set: the adapter sends where the plugin sends" ); - const planeName = plane.route === "opencode" ? "zen" : "go"; const groups = new Map( [ ...candidatesByProtocol( @@ -972,42 +1038,8 @@ describe.skipIf(!LIVE)( }); try { await waitForPlaneOwner(tier.llm, [plane.route]); - const walk = await walkPerProtocol( - groups, - async (protocol, id): Promise => { - const drained = await drainStream(tier.llm, plane.route, id, { - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - 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 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" }; - } + 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 @@ -1021,5 +1053,70 @@ describe.skipIf(!LIVE)( } } ); + + 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/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/host-modules.test.ts b/test/host-modules.test.ts index d59e2bbb..779dd271 100644 --- a/test/host-modules.test.ts +++ b/test/host-modules.test.ts @@ -242,6 +242,125 @@ describe("host-modules · the host's tree, found from the context's base URL", ( }); }); +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(); 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 index 3f1e9f4f..b9349410 100644 --- a/test/mock/plane-dispatch.mock.ts +++ b/test/mock/plane-dispatch.mock.ts @@ -16,6 +16,8 @@ * * @module test/mock/plane-dispatch.mock */ + +import { usesGoogleThinkingLevel } from "@earendil-works/pi-ai/api/google-shared"; import { afterAll, afterEach, @@ -38,7 +40,12 @@ import { type TierA, waitForPlaneOwner, } from "../support/host.ts"; -import { clearCapturedApiKeys, headerValueFor } from "../support/src.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"; @@ -649,3 +656,203 @@ describe("plane dispatch · Go Gemini replay stays on one provider label", () => 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/model-metadata.test.ts b/test/model-metadata.test.ts index 41d1ca6c..9ce786ed 100644 --- a/test/model-metadata.test.ts +++ b/test/model-metadata.test.ts @@ -21,6 +21,7 @@ import { piReasoningFromEfforts, protocolForSdk, reasoningFor, + usesCatalogThinkingLevels, } from "./support/src.ts"; const zen = (id: string): CatalogModelSpec => { @@ -261,3 +262,144 @@ describe("model-metadata · piReasoningFromEfforts (pi-ai's resolveModelReasonin ); }); }); + +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/plane-profile.test.ts b/test/plane-profile.test.ts index 0cfcdae3..2e1b0596 100644 --- a/test/plane-profile.test.ts +++ b/test/plane-profile.test.ts @@ -224,6 +224,113 @@ describe("plane-profile · the field mapping and pi-ai's defaults", () => { }); }); +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( diff --git a/test/plane-provider.test.ts b/test/plane-provider.test.ts index d9155831..533d4d21 100644 --- a/test/plane-provider.test.ts +++ b/test/plane-provider.test.ts @@ -635,4 +635,74 @@ describe("plane-provider · buildPlaneProvider", () => { } } }); + + 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/support/host.ts b/test/support/host.ts index ea569657..e668f016 100644 --- a/test/support/host.ts +++ b/test/support/host.ts @@ -101,9 +101,13 @@ 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 { createProvider } from "@earendil-works/pi-ai/models"; +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"; @@ -112,6 +116,8 @@ import { withOpenCodeSessionHeader } from "@earendil-works/pi-ai/providers/openc import { apply, Config, + type GoogleLevelPieces, + type GoogleLevelSource, type HostLibraries, type HostModuleSource, inject, @@ -1001,6 +1007,30 @@ export const logLines = ( }; }; +/** + * 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 @@ -1023,6 +1053,7 @@ export const hostModulesForTests = (): HostModuleSource => { >, createProvider: createProvider as unknown as HostLibraries["createProvider"], + googleLevels: googleLevelsForTests(), interpolate: interpolate as HostLibraries["interpolate"], LlmError: LlmError as unknown as HostLibraries["LlmError"], opencodeAuth: opencodeProvider().auth, diff --git a/test/support/src.ts b/test/support/src.ts index b6776e77..40ffc5e1 100644 --- a/test/support/src.ts +++ b/test/support/src.ts @@ -147,6 +147,13 @@ export { } 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, @@ -402,6 +409,7 @@ export { PI_LEVELS, piReasoningFromEfforts, reasoningFor, + usesCatalogThinkingLevels, } from "../../src/utils/model-metadata.ts"; // utils/plane-model.ts diff --git a/vite.config.ts b/vite.config.ts index b5f1c677..a1a17eba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -295,6 +295,14 @@ export default defineConfig({ 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, From dec00c80a734d28a8a1ac1f6f9f792d1ed08e1de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:54:18 +0000 Subject: [PATCH 05/20] docs: correct the Go shape count, the README date and the install range Three documentation defects the synced base carries into P-R1. None was caused by the merge (each is identical on the merge-base and on both parents), and each wants the same change on the lowest branch (#105) and, for the README date, on P-R2's cherry-pick of d13cbe2. - PRE-1, docs/redirect-planes.md: "The Go plane serves 12 models on shapes other than completions" restores the count its own table gives (5 openai + 5 anthropic + 1 google = 11, the Go shim's 30 less its 19 no-SDK models). The 12 was right when written (6 + 6) and a later refresh moved the cells but not the sentence. test/catalog.test.ts now holds the sentence to the table's non-completions Go sum, so the next refresh cannot strand it again. - PRE-2, README.md and README.zh-CN.md: the 81-model count is dated 2026-10-27, the refresh that produced it (d13cbe2); on 2026-10-23 the count was 82, so the date no longer matched the number. - PRE-3, README.md and README.zh-CN.md: both install snippets name "^1.4.1" (package.json's version), where they had drifted to ^0.14.0 and ^0.12.0, neither of which can resolve to a 1.x release. test/readme-parity.test.ts pins the two snippets to one range on package.json's major. Both new assertions were checked against the stale values: each fails on them and passes on the corrected text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- docs/redirect-planes.md | 2 +- test/catalog.test.ts | 17 +++++++++++++++-- test/readme-parity.test.ts | 19 ++++++++++++++++++- 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a9b267a1..e85ec31b 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ The package is `dsh-opencode-patch` under every name it is used by: the dependen // ~/.dsh/profiles/web/package.json { "dependencies": { - "dsh-opencode-patch": "^0.14.0", + "dsh-opencode-patch": "^1.4.1", }, "dsh": { "profile": { @@ -166,7 +166,7 @@ Zen's provider-level SDK is `@ai-sdk/openai-compatible`. models.dev names a **di | `@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-23 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 81 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. +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`. Four properties follow: diff --git a/README.zh-CN.md b/README.zh-CN.md index 16070b31..d623158b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -92,7 +92,7 @@ npm install dsh-opencode-patch // ~/.dsh/profiles/web/package.json { "dependencies": { - "dsh-opencode-patch": "^0.12.0", + "dsh-opencode-patch": "^1.4.1", }, "dsh": { "profile": { @@ -164,7 +164,7 @@ Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在 | `@ai-sdk/google` | 8 | Google Generative AI | | `@ai-sdk/mistral` | 1 | OpenAI Chat Completions(与 pi 自己的数据一致) | -数量为 `models.opencode.ai` 中 `opencode` 的 81 个**在用**模型,统计于 2026-10-23 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 81 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 +数量为 `models.opencode.ai` 中 `opencode` 的 81 个**在用**模型,统计于 2026-10-27 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 81 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 由此有四条性质: diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index db52641a..59e139a8 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -35,7 +35,7 @@ A **shape** is one wire protocol, named by the SDK a model declares in the catal 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 12 models on shapes other than completions; both planes now serve every shape either one carries, on the plane's own route (§3). +**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. diff --git a/test/catalog.test.ts b/test/catalog.test.ts index 7694f128..b1a349ca 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -1033,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. @@ -1054,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); @@ -1066,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 () => { diff --git a/test/readme-parity.test.ts b/test/readme-parity.test.ts index b98c2b04..947db2a6 100644 --- a/test/readme-parity.test.ts +++ b/test/readme-parity.test.ts @@ -9,7 +9,9 @@ * 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, and the `keySource` hint's order, which is + * `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). * @@ -77,6 +79,21 @@ describe("README parity", () => { } }); + 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) => { From 61e2c5d9de5fd6be69b5193fccd65a26c9622161 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:44:19 +0000 Subject: [PATCH 06/20] docs: close the audit's remaining findings on P-R1 Each finding the third audit round left open, re-verified on this head: - README.zh-CN.md still says Zen keys never query the Go quota endpoint: already fixed (0cceb81, dec00c8). The feature bullet, step 3, the EntitlementError row and the credential bullet match README.md. - The withdrawn-model fix drops a protocol instead of trying its next model: already fixed (0cceb81). walkPerProtocol walks up to three candidates per protocol and names an unreached protocol in a skip; test/live-walk.test.ts pins both. - N1's claim stands in docs/deep-dive.md and the keySource hint describes auto backwards: already fixed (0cceb81) in deep-dive.md and both hint strings. The same claim survived in a source comment, KEY_SOURCE_ORDER's "tier safety" note in src/go-discovery.ts; this commit corrects it: a key is filed by the plane its request went to, so a Go entry without apiKeyEnv files the Zen key as a Go key. - PR #113 lists counts, not the flipped rows and the four records: fixed in the PR body. - The R2 fix passes the live rows without performing a protocol, the skip decided by message text: already fixed (0cceb81). A "gone" verdict needs a raw probe of the same id to confirm it, and a protocol no candidate reached skips by name. - README.zh-CN.md lacks the N1 fix, the Go apiKeyEnv step and the "Changed in 1.x" bullet: already fixed (0cceb81). - PR #113 omits the D20 Go behaviour change: fixed in the PR body. - docs/deep-dive.md says resolveGoApiKey excludes Zen keys and a Zen-only setup hides the ring: already fixed (0cceb81). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- src/go-discovery.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/go-discovery.ts b/src/go-discovery.ts index 5ef8633c..cf2e56bd 100644 --- a/src/go-discovery.ts +++ b/src/go-discovery.ts @@ -283,9 +283,12 @@ type KeySourceStep = * then credentials/env, then any captured key. `request` promotes both captured * steps above the declared sources; `configured` does the reverse. * - * Tier safety is orthogonal and applies to all three: a step that returns a key - * for the wrong tier is never consulted, because the Go endpoint rejects a Zen - * key outright. + * The captured steps read only keys filed under the Go plane, in all three + * policies — but a key is filed by the plane its request went to, not by what + * it is entitled to. Nothing here can tell a Zen key from a Go key: a Go entry + * without `apiKeyEnv` sends pi's `OPENCODE_API_KEY` on its turns (D20), that + * bearer is filed as a Go key, and `auto` then sends it to the Go usage + * endpoint, which refuses it. Naming the reference on the Go entry is the fix. * * The store is appended to all three, and to none of them in the middle: the * policy orders the sources the user declared, and the installation's store is From 854a3370796fbca36c03ce392fae4ff33c45d6fa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:29:38 +0000 Subject: [PATCH 07/20] chore: follow the vendor's retirement of nemotron-3-ultra-free The vendor retired `nemotron-3-ultra-free` from the Zen plane, so `catalog freshness` gated this pull request (it changes the generator's inputs). `src/catalog-data.ts` is taken exactly as main regenerated it (#132), which today's registry still matches apart from that one row, with no hand edits. What restates the catalog follows it: - Zen 81 -> 80 served, no-SDK 24 -> 23, free 9 -> 8: catalog.test.ts, redirect-planes.md, protocol-routing-and-merge.md and both READMEs ("127 listed" stays; a retired model stays listed as deprecated); - the README model lists, in both languages, now name ids the catalog ships: the Zen and Go lists had kept seven ids the vendor has since retired. The lists are main's; this branch's own prose around them (how the Go Google base is served) is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- README.md | 14 +++++++------- README.zh-CN.md | 14 +++++++------- docs/protocol-routing-and-merge.md | 10 +++++----- docs/redirect-planes.md | 6 +++--- src/catalog-data.ts | 14 -------------- test/catalog.test.ts | 4 ++-- 6 files changed, 24 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index e85ec31b..d6f21298 100644 --- a/README.md +++ b/README.md @@ -142,15 +142,15 @@ Those endpoints authenticate in **three ways**, because they speak three wire sh ### 1. OpenCode Zen (`provider: opencode`) — pay-as-you-go & free tier - **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` +- **OpenAI Responses** (`https://opencode.ai/inference/openai/v1/responses`): `gpt-5.4`, `gpt-5.2`, `gpt-5.1-codex-max`, `muse-spark-1.3`, `grok-4.7`, `muse-wonder-1.0` +- **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 `mimo-v2.6-flash-free`, `nemotron-3.5-lightning-free`, `deepseek-v4.1-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 -- **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` +- **OpenAI Chat Completions** (`https://opencode.ai/inference/go/openai/v1/chat/completions`): `beam-free`, `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.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`, `minimax-m3.1-flash`, `step-5-preview` +- **OpenAI Responses** (`https://opencode.ai/inference/go/openai/v1/responses`): `gpt-5.6-luna`, `gpt-6-luna`, `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-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). @@ -160,13 +160,13 @@ Zen's provider-level SDK is `@ai-sdk/openai-compatible`. models.dev names a **di | models.dev `provider.npm` | models | protocol | | :-- | --: | :-- | -| _(absent)_ | 24 | OpenAI Chat Completions | +| _(absent)_ | 23 | 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`. +Counts are the **80** active `opencode` models in `models.opencode.ai` as of 2026-11-02 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 80 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. Four properties follow: diff --git a/README.zh-CN.md b/README.zh-CN.md index d623158b..de6ec15d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -140,15 +140,15 @@ npm install dsh-opencode-patch ### 1. OpenCode Zen (`provider: opencode`) — 按量计费与免费额度 - **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` +- **OpenAI Responses** (`https://opencode.ai/inference/openai/v1/responses`):`gpt-5.4`、`gpt-5.2`、`gpt-5.1-codex-max`、`muse-spark-1.3`、`grok-4.7`、`muse-wonder-1.0` +- **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`,外加免费层的 `mimo-v2.6-flash-free`、`nemotron-3.5-lightning-free`、`deepseek-v4.1-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`) — 订阅额度 -- **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` +- **OpenAI Chat Completions**(`https://opencode.ai/inference/go/openai/v1/chat/completions`):`beam-free`, `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.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`, `minimax-m3.1-flash`, `step-5-preview` +- **OpenAI Responses**(`https://opencode.ai/inference/go/openai/v1/responses`):`gpt-5.6-luna`, `gpt-6-luna`, `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-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-更新)。 @@ -158,13 +158,13 @@ Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在 | models.dev `provider.npm` | 模型数 | 协议 | | :-- | --: | :-- | -| _(缺失)_ | 24 | OpenAI Chat Completions | +| _(缺失)_ | 23 | 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` 重新生成该文件。 +数量为 `models.opencode.ai` 中 `opencode` 的 80 个**在用**模型,统计于 2026-11-02 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 80 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 由此有四条性质: diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index fc11d638..43465c59 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -8,11 +8,11 @@ OpenCode serves its models on **four different API shapes**, and which one a mod | API shape | Wire endpoint (Zen) | Which models | Signal in the catalog | | :-- | :-- | :-- | :-- | -| OpenAI **Chat Completions** | `POST /inference/openai/v1/chat/completions` | **the default**: 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` | +| OpenAI **Chat Completions** | `POST /inference/openai/v1/chat/completions` | **the default**: 23 of 80 | the model names **no** SDK | +| OpenAI **Responses** | `POST /inference/openai/v1/responses` | 31 of 80 | `@ai-sdk/openai` | +| Anthropic **Messages** | `POST /inference/anthropic/v1/messages` | 17 of 80 | `@ai-sdk/anthropic` | +| Google **Generative AI** | `POST /inference/google/v1beta/models/:streamGenerateContent` | 8 of 80 | `@ai-sdk/google` | +| OpenAI **Chat Completions** (as pi files it) | `POST /inference/openai/v1/chat/completions` | 1 of 80 | `@ai-sdk/mistral` | Counts are the shipped catalog. They are asserted against this table by `catalog.test.ts`, because a count written into prose does not regenerate with the data it counts. Go's endpoints are the same paths under `/inference/go/…`; `endpoint: legacy` sends every protocol to `/zen/v1` (`/zen/go/v1` on Go), except Go's Google, which only the inference family serves. diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 59e139a8..0ed109ab 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -10,7 +10,7 @@ A **plane** is one gateway base URL with its own catalog and its own credential. | Plane | Route id | Base URL | Credential | Models | | :-- | :-- | :-- | :-- | --: | -| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | the entry's `apiKeyEnv`; without one, pi's `OPENCODE_API_KEY` | 81 served / 127 listed | +| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | the entry's `apiKeyEnv`; without one, pi's `OPENCODE_API_KEY` | 80 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. @@ -25,13 +25,13 @@ A **shape** is one wire protocol, named by the SDK a model declares in the catal | SDK in the catalog | Protocol | Zen | Go | | :------------------ | :--------------------- | --: | ----: | -| _(none)_ | `openai-completions` | 24 | 19 | +| _(none)_ | `openai-completions` | 23 | 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. +**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 80 on Zen, 0 of 30 on Go. A model that speaks the completions shape simply omits `provider.npm` and inherits it. So "no SDK" does not mean "unknown"; it means _the provider default_, which is the protocol the route was itself registered with. That is why the servability check treats `undefined` as servable, and why `@ai-sdk/openai-compatible` is deliberately **not** in `PROTOCOL_FOR_SDK`: listing it would record the default as though it were an override. The coverage guard still covers the case where a model declares it explicitly. None do today, and if one did, the test would name it rather than drop the model. diff --git a/src/catalog-data.ts b/src/catalog-data.ts index a9285d67..f3436599 100644 --- a/src/catalog-data.ts +++ b/src/catalog-data.ts @@ -1820,20 +1820,6 @@ export const OPENCODE_ZEN_CATALOG: readonly CatalogModelSpec[] = [ }, ], }, - { - context_window: 1_000_000, - cost: { - cache_read: 0, - input: 0, - output: 0, - }, - id: "nemotron-3-ultra-free", - input_modalities: ["text"], - is_free: true, - max_output_tokens: 128_000, - name: "Nemotron 3 Ultra Free", - reasoning: true, - }, { context_window: 262_144, cost: { diff --git a/test/catalog.test.ts b/test/catalog.test.ts index b1a349ca..73117e4b 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -194,12 +194,12 @@ describe("OpenCode Model Catalog & Enrichment", () => { // replacing the whole set — so these counts are the cold-start picker, and a // silent drop here is a model nobody can pick. Regenerate with // `scripts/regenerate-catalog-shim.ts` rather than editing by hand. - expect(OPENCODE_ZEN_CATALOG.length).toBe(81); + expect(OPENCODE_ZEN_CATALOG.length).toBe(80); const freeModels = OPENCODE_ZEN_CATALOG.filter((m) => m.is_free === true); // Only the free tiers the registry still lists as active. The count is the // `is_free` flag the shim carries, NOT the `-free` suffix: `big-pickle` is // flagged and is not suffixed, so a suffix rule would have missed it. - expect(freeModels.length).toBe(9); + expect(freeModels.length).toBe(8); const ids = new Set(OPENCODE_ZEN_CATALOG.map((m) => m.id)); expect(ids.has("beam-free")).toBe(true); From b0f0bdc3bd1b1f597941a1b77d57c51bb00d2215 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:29:38 +0000 Subject: [PATCH 08/20] test: walk past a model the gateway answers ModelDeprecated for The live gateway job failed on `gemini-3-flash`: the registry still lists it, and the gateway now answers it with an error of type `ModelDeprecated`. The adapter walk moves past a withdrawn model only when both its pre-filter (`isModelGoneMessage`) and the raw probe that confirms it (`modelGone`) recognise the answer, and neither knew this shape, so a vendor retirement read as the plugin's refusal. Both now recognise `ModelDeprecated` (by type, at any status, as main's `retiredAtTheGateway` does). `test/live-probe.test.ts` is new and holds `modelGone` to each shape; `keyless-matrix.test.ts` gains the message row. Removing either rule fails its test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- test/keyless-matrix.test.ts | 5 +++++ test/live-probe.test.ts | 31 +++++++++++++++++++++++++++++++ test/support/keyless-matrix.ts | 6 ++++-- test/support/live-probe.ts | 14 +++++++++----- 4 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 test/live-probe.test.ts diff --git a/test/keyless-matrix.test.ts b/test/keyless-matrix.test.ts index e409fecb..e37e47a4 100644 --- a/test/keyless-matrix.test.ts +++ b/test/keyless-matrix.test.ts @@ -343,6 +343,11 @@ describe("a live turn's failure message that says the model is gone", () => { ["410 status code (no body)", true], ["400 Upstream request failed: Model is unavailable", true], ["model is UNAVAILABLE", true], + [ + '{"type":"error","error":{"type":"ModelDeprecated","message":"Model gemini-3-flash has been deprecated."}}', + true, + ], + ["ModelDeprecatedish", false], ["400 Invalid request: messages is required", false], ["4100 tokens exceeded", false], ["", false], diff --git a/test/live-probe.test.ts b/test/live-probe.test.ts new file mode 100644 index 00000000..895ff266 --- /dev/null +++ b/test/live-probe.test.ts @@ -0,0 +1,31 @@ +/** + * When a raw probe confirms the gateway withdrew a model. + * + * The live adapter blocks walk past a candidate only once {@link modelGone} + * says so, so a shape it misses turns the vendor retiring a model into a red + * live run (it did, for `ModelDeprecated`, on 2026-11-02). + * + * @module test/live-probe.test + */ +import { describe, expect, it } from "vitest"; + +import { modelGone } from "./support/live-probe.ts"; + +describe("a raw probe that says the model is gone", () => { + it.each([ + [410, "410 ", true], + [400, "400 Upstream request failed: Model is unavailable", true], + [ + 400, + '400 {"type":"error","error":{"type":"ModelDeprecated","message":"Model gemini-3-flash has been deprecated."}}', + true, + ], + [404, '404 {"error":{"type":"ModelDeprecated"}}', true], + [400, "400 Invalid request: messages is required", false], + [404, "404 Not Found", false], + [401, "401 Invalid API key", false], + [200, "200 ok", false], + ] as const)("%i %j → %s", (status, summary, gone) => { + expect(modelGone({ status, summary })).toBe(gone); + }); +}); diff --git a/test/support/keyless-matrix.ts b/test/support/keyless-matrix.ts index 1bd25a67..c7ec39ab 100644 --- a/test/support/keyless-matrix.ts +++ b/test/support/keyless-matrix.ts @@ -258,7 +258,9 @@ export const isTransientProbeAnswer = (status: number | undefined): boolean => * * 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 + * failure message rather than a raw response, plus the gateway's + * `ModelDeprecated` error type, which it now answers for a model the catalog + * still lists (measured 2026-11-02 on `gemini-3-flash`). 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 @@ -269,7 +271,7 @@ export const isTransientProbeAnswer = (status: number | undefined): boolean => * @returns whether the message names a withdrawn model. */ export const isModelGoneMessage = (message: string): boolean => - /\b410\b|model is unavailable/iu.test(message); + /\b410\b|model is unavailable|\bModelDeprecated\b/iu.test(message); /** * Read one keyless answer. diff --git a/test/support/live-probe.ts b/test/support/live-probe.ts index 4717fb93..cc5227f6 100644 --- a/test/support/live-probe.ts +++ b/test/support/live-probe.ts @@ -168,14 +168,18 @@ export const sendProbe = async (input: { /** * 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. + * `400 Upstream request failed: Model is unavailable`, `410` and an error of + * type `ModelDeprecated` 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, and 2026-11-02 on `gemini-3-flash`, answered + * `{"type":"ModelDeprecated"}` while the registry still listed it). 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.summary.includes("ModelDeprecated") || (result.status === 400 && result.summary.includes("Model is unavailable")); /** From 979ea277b9dbb9a56fdee0863630487bf644b7a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:31:47 +0000 Subject: [PATCH 09/20] chore(vendor): follow opencode 1.19.5 The vendor published `opencode/1.19.5`, and `cli:version` gates this pull request because its diff touches that check's inputs. Main made the same bump in #132 after comparing every cited vendor source between the two tags: the one that moved (`session/llm/request.ts`) only dropped an unused type import, so no header the plugin emulates changed. This takes the same bump in the 17 files that carry a copy here; `cli:version --copies` reports all 58 copies and mentions at 1.19.5. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- docs/credentials.md | 2 +- docs/deep-dive.md | 2 +- docs/engineering-notes.md | 22 +++++++++++----------- docs/opencode-1.19-api.md | 10 +++++----- docs/opencode-parity-plan.md | 4 ++-- docs/redirect-planes.md | 2 +- docs/upstream-findings.md | 26 +++++++++++++------------- scripts/verify-session-band.ts | 2 +- src/fetch-patch.ts | 8 ++++---- src/go-discovery.ts | 2 +- src/identity.ts | 2 +- src/utils/session-id.ts | 2 +- test/client-headers-affinity.test.ts | 10 +++++----- test/e2e/patched-fetch-headers.e2e.ts | 6 +++--- test/fetch-patch.test.ts | 2 +- test/go-discovery.test.ts | 2 +- test/mock/testing-kit.ts | 4 ++-- 17 files changed, 54 insertions(+), 54 deletions(-) diff --git a/docs/credentials.md b/docs/credentials.md index 03bbe535..219e1798 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -348,7 +348,7 @@ Every combination below was sent to `https://opencode.ai/zen/v1` against a Zen ` | :-- | :-- | | credential | bare API key (`oc_sk_…`, 51 chars) · account session token (`st_…`, 39 chars) · the token **with** its org (`wrk_…`) · **no credential at all** · the literal `public`, the client's own keyless value | | structure | bare body · with the `read` and `bash` tool schemas the fallback injects | -| headers | `x-opencode-client: cli` · `user-agent: opencode/1.19.4` · a valid `x-opencode-session-id` (`ses_…`) · **the complete set captured from the client**, `x-opencode-request` included | +| headers | `x-opencode-client: cli` · `user-agent: opencode/1.19.5` · a valid `x-opencode-session-id` (`ses_…`) · **the complete set captured from the client**, `x-opencode-request` included | | endpoint | `/chat/completions` · `/responses` · `/inference/openai/v1` | | runtime | Node's fetch · Python's urllib · **Bun**, the runtime the real client ships on | diff --git a/docs/deep-dive.md b/docs/deep-dive.md index 33a35d3e..b6135510 100644 --- a/docs/deep-dive.md +++ b/docs/deep-dive.md @@ -35,7 +35,7 @@ The fetch patch satisfies every variant: | `x-session-affinity` | `ses_<12hex><14base62>` | Generic proxy/relay affinity (Cloudflare AI Gateway, LiteLLM, Portkey) | | `x-opencode-parent-session-id` | `ses_` | Hierarchical lineage for DSH subagents (`subagent`, `subagent_fork`) | | `x-parent-session-id` | `ses_` | Generic proxy parent-session affinity | -| `User-Agent` | `opencode/1.19.4 …` | Prevents Cloudflare WAF Error 1010 challenges. The constant lives in `identity.ts`, aliased as `OPENCODE_UA` in `session.ts`; the patch version tracks the CLI | +| `User-Agent` | `opencode/1.19.5 …` | Prevents Cloudflare WAF Error 1010 challenges. The constant lives in `identity.ts`, aliased as `OPENCODE_UA` in `session.ts`; the patch version tracks the CLI | | `x-opencode-client` | `cli` (configurable) | Identifies the client tier to the Zen gateway | | `x-opencode-project` | dynamic / `global` | Workspace project attribution for the Console | diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 742c2d62..360193d2 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -306,7 +306,7 @@ Four instances in one session is no longer an accident of care. The rule that ca The file is 190 lines and had only ever been grepped, which is how two findings survived in it: the auto-review section kept the "on the same model" conclusion this work overturned, and section 5's neighbourhood exposed a code comment still describing a credential-prefix guard that was removed. -Sections 1 to 3 check out. The header matrix's version claim is the checkable one — `OPENCODE_CLI_VERSION` is `1.19.4` in `identity.ts`, and `pnpm run cli:version` confirms it is the published version with seven copies agreeing — and the rest of the matrix is a decompilation whose values match what the patch sends. +Sections 1 to 3 check out. The header matrix's version claim is the checkable one — `OPENCODE_CLI_VERSION` is `1.19.5` in `identity.ts`, and `pnpm run cli:version` confirms it is the published version with seven copies agreeing — and the rest of the matrix is a decompilation whose values match what the patch sends. The remaining claim in section 1 that cannot be checked from here is the `User-Agent`'s purpose: that it prevents Cloudflare WAF Error 1010. It is not the cause of the free-tier refusal, which was measured exhaustively and is about the model rather than the request, and whether it defeats a WAF challenge is not something this repository can observe. @@ -441,9 +441,9 @@ Evidence, all read: ## The tool schemas were fixed against the wrong generation, and are back -Read from `git show v1.19.4:packages/opencode/src/tool/read.ts:28-36` and `tool/shell/prompt.ts:15-23`, against the 2.x files I had read: +Read from `git show v1.19.5:packages/opencode/src/tool/read.ts:28-36` and `tool/shell/prompt.ts:15-23`, against the 2.x files I had read: -| | 1.19.4 (what this plugin emulates) | 2.x (what I read) | +| | 1.19.5 (what this plugin emulates) | 2.x (what I read) | | :-- | :-- | :-- | | `read` required key | **`filePath`** | `path` | | `read` offset/limit descriptions | "The line number to start reading from (1-indexed)" / "The maximum number of lines to read (defaults to 2000)" | "…directory entry…" / "…defaults to and capped at 2000" | @@ -451,14 +451,14 @@ Read from `git show v1.19.4:packages/opencode/src/tool/read.ts:28-36` and `tool/ | shell parameters | `command`, `timeout`, `workdir` | `command`, `workdir`, `timeout`, **`background`** | | shell `timeout` description | "Optional timeout in milliseconds" | "Set to 0 to disable… defaults to 120000…" | -So the round that "fixed the tool schemas" renamed `filePath` to `path` and added `background` — both correct for 2.x and wrong for the generation this plugin emulates. Reverted. This is the third time in this session that reading the `dev` branch produced a change the `v1.19.4` tag contradicts, after the session headers and the affinity split; the branch layout is the first thing to establish, not the last. +So the round that "fixed the tool schemas" renamed `filePath` to `path` and added `background` — both correct for 2.x and wrong for the generation this plugin emulates. Reverted. This is the third time in this session that reading the `dev` branch produced a change the `v1.19.5` tag contradicts, after the session headers and the affinity split; the branch layout is the first thing to establish, not the last. Other deltas the comparison establishes, none of them acted on yet: -- `x-opencode-request` **is** sent by 1.19.4 (the user message id) and was dropped in 2.x. This plugin omitted it for a round — the turn store held no message id — and now sends one minted per turn in the same shape; the section below records how and why. -- `x-session-affinity` and `X-Session-Id` are **not** sent for `opencode*` providers in 1.19.4 — they are the other branch, for relays. This plugin sends them on every claimed request. -- `User-Agent` is `opencode/` in 1.19.4, and `opencode///` in 2.x. The plugin sends `opencode/1.19.4 ai-sdk/provider-utils/… runtime/bun/…`, which is neither. -- Chat bodies use `max_tokens` in 1.19.4 and `max_completion_tokens` in 2.x. +- `x-opencode-request` **is** sent by 1.19.5 (the user message id) and was dropped in 2.x. This plugin omitted it for a round — the turn store held no message id — and now sends one minted per turn in the same shape; the section below records how and why. +- `x-session-affinity` and `X-Session-Id` are **not** sent for `opencode*` providers in 1.19.5 — they are the other branch, for relays. This plugin sends them on every claimed request. +- `User-Agent` is `opencode/` in 1.19.5, and `opencode///` in 2.x. The plugin sends `opencode/1.19.5 ai-sdk/provider-utils/… runtime/bun/…`, which is neither. +- Chat bodies use `max_tokens` in 1.19.5 and `max_completion_tokens` in 2.x. - `b3` is absent in **both** generations, and `traceparent` appears only under `experimental.openTelemetry` — so neither belongs in the plugin's request on the evidence available. ## The two gaps the failed subagent left, answered directly @@ -467,9 +467,9 @@ It died without a closing message, so the four questions it was given were answe **`x-opencode-project` is not a hash — it is the git root's path.** `packages/core/src/project.ts:307-315` is `root.replace(/\.git\/?$/, "")` fed to `ID.make`, so the identifier is the working-copy root with the `.git` suffix stripped. A subproject or worktree does not get its own id: `project.ts:356-376` resolves through `repo.commonDirectory` and takes that store's cached id, so a worktree shares its main repository's — which is what "workspace project attribution" means. With no VCS at all the id is `ID.global` (`:348`, `:376`), and that is the case the plugin's current `"global"` actually matches. So for a real repository the plugin sends the wrong value, and the right one is a path rather than a digest. -**`x-opencode-request` is a user-message id, and it can be supplied.** 1.19.4 sends `input.user.id` (`request.ts:194`), and the value is `input.messageID ?? MessageID.ascending()` (`session/prompt.ts:471` and `:657`) — so it is the id of the user's message, not of a turn, and the CLI accepts one from its caller rather than always minting one. The capture of the running client shows the shape as `msg_…`. That matters for this plugin: the turn store held no message id, which is why the header was absent for a round, and a host-supplied value would still be the faithful one — what shipped instead is a minted id in the same shape, because the one consumer the trees show is a metric and a per-turn value satisfies it without a host change. See the section below. +**`x-opencode-request` is a user-message id, and it can be supplied.** 1.19.5 sends `input.user.id` (`request.ts:194`), and the value is `input.messageID ?? MessageID.ascending()` (`session/prompt.ts:471` and `:657`) — so it is the id of the user's message, not of a turn, and the CLI accepts one from its caller rather than always minting one. The capture of the running client shows the shape as `msg_…`. That matters for this plugin: the turn store held no message id, which is why the header was absent for a round, and a host-supplied value would still be the faithful one — what shipped instead is a minted id in the same shape, because the one consumer the trees show is a metric and a per-turn value satisfies it without a host change. See the section below. -Still open from that subagent's brief: the exact AI SDK wire body on 1.19.4's default path (`max_tokens` versus `max_completion_tokens`, `stream_options`, `store`, `prompt_cache_key`, `strict`) — the pinned AI SDK versions are not in this checkout and the earlier comparison flagged its own answer as read from a different version. +Still open from that subagent's brief: the exact AI SDK wire body on 1.19.5's default path (`max_tokens` versus `max_completion_tokens`, `stream_options`, `store`, `prompt_cache_key`, `strict`) — the pinned AI SDK versions are not in this checkout and the earlier comparison flagged its own answer as read from a different version. ## The mount's base URL should be data, and what it takes to make it so @@ -488,7 +488,7 @@ Steps 1 and 2 alone change nothing, and step 3 without 1 or 2 moves a constant. ## `x-opencode-request` is sent again — minted, because the message id is not reachable from here -1.19.4 sends it (`request.ts:194`) and 2.x dropped it. It is the user's message id — `input.user.id`, set from `input.messageID ?? MessageID.ascending()` (`session/prompt.ts:471`, `:657`) — so the CLI is happy to be handed one rather than minting it, and the running client shows the shape as `msg_…`. +1.19.5 sends it (`request.ts:194`) and 2.x dropped it. It is the user's message id — `input.user.id`, set from `input.messageID ?? MessageID.ascending()` (`session/prompt.ts:471`, `:657`) — so the CLI is happy to be handed one rather than minting it, and the running client shows the shape as `msg_…`. The plugin's turn state carries `model`, `parentValue`, `project`, `provider` and `value` (`src/turn-store.ts`), and none of them is a message id: `value` is the session's derived id, which is a different object from the message. The stream hook's `options` carries no message either — the adapter builds the body after the hook returns — so there is nothing at this layer to send. This section said for a round that "inventing a value would be worse than omitting the header", and the premise turned out to be the part that was wrong: the gateway reads this header as a metric (`packages/console/app/src/routes/zen/util/handler.ts:127-135`) and the console rewrites it to `x-opencode-request-id` before forwarding, so **what the header has to satisfy is its shape and its one-value-per-request lifecycle — not the CLI's own message table**, which nothing reads back. diff --git a/docs/opencode-1.19-api.md b/docs/opencode-1.19-api.md index 28fd2086..b1c2874e 100644 --- a/docs/opencode-1.19-api.md +++ b/docs/opencode-1.19-api.md @@ -1,12 +1,12 @@ -# OpenCode 1.19.4: the API that moved +# OpenCode 1.19.5: the API that moved -A specification of what this plugin talks to, what OpenCode 1.19.4 talks to instead, and what a migration would have to change. Written because the difference is not cosmetic: an API key that authenticates a paid model is refused for a free one, and the free tier is where this plugin's original reason for existing lives. +A specification of what this plugin talks to, what OpenCode 1.19.5 talks to instead, and what a migration would have to change. Written because the difference is not cosmetic: an API key that authenticates a paid model is refused for a free one, and the free tier is where this plugin's original reason for existing lives. Everything below is measured. Where something is not, it says so. ## The pin, measured -`OPENCODE_CLI_VERSION` names 1.19.4 — the 1.x line's last release and the generation this plugin emulates. The 2.x line ships as `0.0.0--*` snapshots under npm's `dev`, `next` and `beta` tags, and nothing in this tree claims it. +`OPENCODE_CLI_VERSION` names 1.19.5 — the 1.x line's last release and the generation this plugin emulates. The 2.x line ships as `0.0.0--*` snapshots under npm's `dev`, `next` and `beta` tags, and nothing in this tree claims it. The emulated surface was compared tag to tag across the 1.19 line rather than assumed: `packages/opencode/src/session/` and `packages/opencode/src/tool/` are byte-identical at all four tags, and so is the affinity split in `provider.ts` — so the request shape, the session headers, the tool schemas and the body keys stand as measured. The patches in between touched only the CLI's own surfaces: session sharing discontinued, then the install/upgrade path, then how configured `reasoning_options` become model variants (`provider.ts`, `transform.ts`), plus generated SDK types. @@ -26,7 +26,7 @@ The difference is in what a credential can reach — but **not the way this docu So a deployment's API key keeps working exactly as it does today. **Logging in does not make the free tier reachable** — that was this document's claim and it is false, measured with the account's session token and its org. What a login buys is a credential for paid models; the free tier is not granted to anything sent from here. -## What OpenCode 1.19.4 resolves for itself +## What OpenCode 1.19.5 resolves for itself `opencode debug config` prints the configuration the running client actually uses. For the `opencode` provider, on this machine, with an account signed in: @@ -58,7 +58,7 @@ Pointing the real client at a local server (`OPENCODE_CONFIG_CONTENT` overriding ``` POST /v1/chat/completions authorization: Bearer st_3624847b-9048-4656-87ab-309f1516f6bb -user-agent: opencode/1.19.4 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14 +user-agent: opencode/1.19.5 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14 x-opencode-client: cli x-opencode-org-id: wrk_01KWJYKNFF34P8F0JZDBX1WTB8 x-opencode-project: global diff --git a/docs/opencode-parity-plan.md b/docs/opencode-parity-plan.md index b4d9f633..a6c324dc 100644 --- a/docs/opencode-parity-plan.md +++ b/docs/opencode-parity-plan.md @@ -27,8 +27,8 @@ The table below outlines OpenCode's native features and the corresponding implem | **2. Gateway `/models` Emulation** | Does not call `/models`; uses internal catalog dictionary directly | Intercepts `GET .../models` via `fetchPatch`, overlays real context limits, token caps, and modalities via `enrichModelsResponse` | **Full Parity** | | **3. Multi-Protocol Wire Routing** | Dispatches via `@ai-sdk/openai`, `@ai-sdk/anthropic`, `@ai-sdk/google` per model | `internalRouteFor` inspects model ID & SDK, redirecting via mounted child adapters (`opencode-responses`, `opencode-anthropic`, `opencode-google`) | **Full Parity** | | **4. Cross-Plane Model Resolution** | Selects plane based on account subscription and model ID prefix | Cross-plane fallback: routes Go-specific models (e.g. `muse-spark-1.3-contributor`) to `opencode-go-responses` automatically | **Full Parity** | -| **5. Session Cache Affinity** | Attaches `x-opencode-session-id` and `x-opencode-session` (v1.19.4 parity) | Generates `ses_<12hex><14base62>`, injects unified session and affinity headers, supports subagent parent session tracking | **Full Parity** | -| **6. Client & Project Attribution** | Sends `User-Agent: opencode/1.19.4 (...)`, `x-opencode-client`, `x-opencode-project` | `fetchPatch` injects standard CLI User-Agent, `x-opencode-client: cli`, and workspace folder name | **Full Parity** | +| **5. Session Cache Affinity** | Attaches `x-opencode-session-id` and `x-opencode-session` (v1.19.5 parity) | Generates `ses_<12hex><14base62>`, injects unified session and affinity headers, supports subagent parent session tracking | **Full Parity** | +| **6. Client & Project Attribution** | Sends `User-Agent: opencode/1.19.5 (...)`, `x-opencode-client`, `x-opencode-project` | `fetchPatch` injects standard CLI User-Agent, `x-opencode-client: cli`, and workspace folder name | **Full Parity** | | **7. Credential Precedence Ladder** | Request Key → Declared Env → OAuth Token → Local SQLite Store (`opencode.db`) | Request Key → Captured Key → Env (`OPENCODE_API_KEY`/`OPENCODE_GO_API_KEY`) → OAuth Token Store → Local SQLite (`opencode-store.ts`) | **Full Parity** | | **8. Console OAuth Device Flow** | Device flow via `/auth/device/code`, `/auth/device/token`, `/api/user`, `/api/orgs` | Implemented via `console-auth.ts`, `login-flow.ts`, and atomic `token-store.ts` with auto-polling and rate-limit handling | **Full Parity** | | **9. Organization Scoping** | Sends `x-org-id: wrk_...` for Console tokens; omits for static API keys | Attaches `x-opencode-org-id` exclusively for OAuth grants; strictly omitted for static keys | **Full Parity** | diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 0ed109ab..fc494150 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -141,7 +141,7 @@ Measured 2026-10-10 by building one with pi's own protocol implementations and i **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. -**One detail left open:** the Google implementation sets its own `User-Agent` inside the protocol, so ours arrives appended rather than replacing it (`pi (darwin …), opencode/1.19.4 …`). Whether the gateway accepts the combined value or wants a single agent string is unverified. +**One detail left open:** the Google implementation sets its own `User-Agent` inside the protocol, so ours arrives appended rather than replacing it (`pi (darwin …), opencode/1.19.5 …`). Whether the gateway accepts the combined value or wants a single agent string is unverified. ## 10. A catalog route serves every protocol already diff --git a/docs/upstream-findings.md b/docs/upstream-findings.md index 6f71627d..95f076d6 100644 --- a/docs/upstream-findings.md +++ b/docs/upstream-findings.md @@ -12,7 +12,7 @@ The payload we send is negotiated with whatever gateway the account is on, and b | | 1.19.x (primary) | 2.0.x (tracked) | | :-- | :-- | :-- | -| Latest at time of writing | 1.19.4 | 2.0.32 | +| Latest at time of writing | 1.19.5 | 2.0.32 | | Session id generator | `packages/opencode/src/id/id.ts` **and** `packages/schema/src/identifier.ts` | `packages/schema/src/identifier.ts` only (`packages/opencode/` is gone) | | Header assembly | `packages/core/src/session/runner/llm.ts` | `packages/core/src/session/model-request.ts` | | Gateway shape | `opencode.ai/zen/v1/…` | `opencode.ai/inference//v1/…` | @@ -26,7 +26,7 @@ The sibling checkout cannot `git fetch` under the DSH sandbox: git writes `.git/ git clone --filter=blob:none --no-checkout --single-branch --branch dev --depth 40 \ https://github.com/anomalyco/opencode.git # then, for a specific version's file: -git fetch --depth 1 origin tag v1.19.4 +git fetch --depth 1 origin tag v1.19.5 ``` Note the remote is `anomalyco/opencode`, **not** `opencode-ai/opencode`. @@ -37,7 +37,7 @@ Note the remote is `anomalyco/opencode`, **not** `opencode-ai/opencode`. ### 1.1 The session id generator — unchanged across both lines -Read in full from both `packages/schema/src/identifier.ts` (present in 1.19.4 and 2.0.32, byte-identical) and `packages/opencode/src/id/id.ts` (1.19.4 only, same arithmetic): +Read in full from both `packages/schema/src/identifier.ts` (present in 1.19.5 and 2.0.32, byte-identical) and `packages/opencode/src/id/id.ts` (1.19.5 only, same arithmetic): ```ts let lastTimestamp = 0; @@ -70,7 +70,7 @@ Three facts, each of which contradicts something this plugin once assumed: **Implication for the rate-limit claim** — see §3.1. -### 1.2 The header contract — 1.19.4 +### 1.2 The header contract — 1.19.5 `packages/core/src/session/runner/llm.ts`, the authoritative assembly: @@ -91,7 +91,7 @@ const request = LLM.request({ … ``` -Five headers, all carrying **`session.id`** — the plain session id, with no derivation, no hash and no band. 1.19.4 does **not** send `x-opencode-session`, `x-opencode-client`, `x-opencode-project` or `User-Agent` from this path. +Five headers, all carrying **`session.id`** — the plain session id, with no derivation, no hash and no band. 1.19.5 does **not** send `x-opencode-session`, `x-opencode-client`, `x-opencode-project` or `User-Agent` from this path. ### 1.3 The header contract — 2.0.32 @@ -128,7 +128,7 @@ Differences that matter to us: ### 1.4 The gateway path table — identical in both lines -`packages/console/app/src/lib/inference-proxy.ts`, present and identical in 1.19.4 and 2.0.32: +`packages/console/app/src/lib/inference-proxy.ts`, present and identical in 1.19.5 and 2.0.32: | Zen path | Inference path | | :----------------------------------------- | :----------------------------- | @@ -146,7 +146,7 @@ Differences that matter to us: ### 1.5 Free-tier access — `allowAnonymous`, and it does NOT gate on a session band -1.19.4, `packages/console/app/src/routes/zen/util/handler.ts`: +1.19.5, `packages/console/app/src/routes/zen/util/handler.ts`: ```ts const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey @@ -179,12 +179,12 @@ const ModelSchema = z.object({ - A free model is admitted with **no key at all**, or with the literal key `public`. (Admitted into the proxy's forwarding path — not the same as served: see `docs/credentials.md` for what the Zen free tier actually answers.) - The gate is a **per-model boolean in the vendor's own model registry**, not a property of the session id, the request headers, or the endpoint family. -- With `anonymous`, the proxy strips `authorization`, `x-api-key` and `x-goog-api-key` and forwards keyless; 1.19.4 has an extra rule 2.x drops — `if (anonymous && !generation?.anonymous) return undefined` — so **1.19.4 requires the gateway's own generation path to declare the model anonymous**, while 2.x forwards any keyless request straight through. +- With `anonymous`, the proxy strips `authorization`, `x-api-key` and `x-goog-api-key` and forwards keyless; 1.19.5 has an extra rule 2.x drops — `if (anonymous && !generation?.anonymous) return undefined` — so **1.19.5 requires the gateway's own generation path to declare the model anonymous**, while 2.x forwards any keyless request straight through. - `billingSource` is `"anonymous"`, `"free"`, `"byok"`, `"subscription"`, `"lite"` or `"balance"`, and `"anonymous"` skips accounting entirely. **The session id's low bits are not part of the `allowAnonymous` rule** — that gate is per model and says nothing about sessions. It is a SEPARATE rule, and §3.1 records it: the gateway does enforce a band on the session id's low twelve bits, and it is enforced independently of what the body carries and of which credential is present. -`allowAnonymous` is **not present in the 2.x snapshot** (`packages/core/src/models-dev/snapshot.txt` carries no such flag, and the 2.x handler file no longer has the key). How a 2.x account decides free access is **unverified**; the 1.19.4 rule is the one we implement against. +`allowAnonymous` is **not present in the 2.x snapshot** (`packages/core/src/models-dev/snapshot.txt` carries no such flag, and the 2.x handler file no longer has the key). How a 2.x account decides free access is **unverified**; the 1.19.5 rule is the one we implement against. --- @@ -192,7 +192,7 @@ const ModelSchema = z.object({ ### 2.1 A per-turn message id — `x-opencode-request` is sent again (2026-10-18) -Recorded in [`docs/engineering-notes.md`](./engineering-notes.md). OpenCode 1.19.4 sent `x-opencode-request` carrying the user message's id (`input.user.id` in `packages/opencode/src/session/llm/request.ts`, in the same branch as `x-opencode-client` and `x-opencode-project`); 2.x dropped it. This was blocked for a round because the turn state carries no message id — `value` is the session's derived id, a different object — and the hook's options carry none either, because the adapter builds the body after the hook returns. +Recorded in [`docs/engineering-notes.md`](./engineering-notes.md). OpenCode 1.19.5 sent `x-opencode-request` carrying the user message's id (`input.user.id` in `packages/opencode/src/session/llm/request.ts`, in the same branch as `x-opencode-client` and `x-opencode-project`); 2.x dropped it. This was blocked for a round because the turn state carries no message id — `value` is the session's derived id, a different object — and the hook's options carry none either, because the adapter builds the body after the hook returns. **Lifted by minting rather than by plumbing a host value**: `newOpenCodeMessageId` in `src/session.ts` produces a fresh `msg_` + 12 hex + 14 base62 value per turn (clock + counter in the hex field, `randomBytes` in the suffix), `stream-hook.ts` stores it as `requestValue` in the turn state, and `fetch-patch.ts` sends it beside the client and project headers. A minted id is not literally the user's message id, and the trade is deliberate: nothing in either released tree reads the value back the way session affinity reads a session id — the console forwards it to the inference service as `x-opencode-request-id` for per-request accounting — so a well-formed value with the client's own lifecycle is as much as a request can honestly carry. Deriving it from message content was rejected rather than overlooked: a digest of the user's own text travelling in a header is a guessable fingerprint, and the vendor's ids are random. @@ -299,7 +299,7 @@ A throw out of the entry is a meter that disappears with no message. The guard i ## 3.7 The Go quota endpoint has exactly one public route — VERIFIED -`packages/console/app/src/routes/zen/go/v1/usage.ts` is the **only** quota route in either major version (checked 1.19.4 and 2.0.32), and the inference proxy's path table (`packages/console/app/src/lib/inference-proxy.ts`) lists `"GET /zen/go/v1/usage": "/go/v1/usage"` with **no** `/inference/.../usage` counterpart. +`packages/console/app/src/routes/zen/go/v1/usage.ts` is the **only** quota route in either major version (checked 1.19.5 and 2.0.32), and the inference proxy's path table (`packages/console/app/src/lib/inference-proxy.ts`) lists `"GET /zen/go/v1/usage": "/go/v1/usage"` with **no** `/inference/.../usage` counterpart. That matters because the live catalog names the INFERENCE family for the `opencode` provider (`api: https://opencode.ai/inference/openai/v1`). `toGoBaseURL` rewrote only the `zen/v1` form, so a base discovered from a provider row passed through untouched and the meter asked `…/inference/openai/v1/usage` — a path that does not exist, which is an unavailable meter with no explanation. Both families now resolve to `https://opencode.ai/zen/go/v1`. @@ -317,7 +317,7 @@ https://opencode.ai/inference/go/openai/v1/usage 404 Reading the source for the answer to "which endpoint does OpenCode use for usage" turns up something the plugin should record, because it changes what the endpoint _is_. -**Nothing in the vendor's client fetches it.** A full-text sweep of the 1.19.4 tree for `usage` finds exactly one reference to the path, and it is the SERVER side: +**Nothing in the vendor's client fetches it.** A full-text sweep of the 1.19.5 tree for `usage` finds exactly one reference to the path, and it is the SERVER side: ``` packages/console/app/src/routes/zen/go/v1/usage.ts:18 "GET /zen/go/v1/usage": "/go/v1/usage" @@ -327,7 +327,7 @@ The console's dashboard does not go through the gateway for this number either So the route is not "how the client asks for usage". It is a **key-facing** endpoint: the route authenticates with `authorization: Bearer `, looks the key up in the key table, and answers `403 EntitlementError` ("OpenCode Go subscription required.") when its workspace has no Go subscription. That is exactly the shape this plugin uses — a key in hand, the windows in hand — and it is why the endpoint is so sparse: it exists for key holders, not for the dashboard. -The two versions differ in where the number comes from, and the shape does not change: 1.19.4 tries the inference proxy first and falls back to its own database when the proxy is unavailable (`.catch(inferenceUnavailable)`), while 2.0.32 forwards the request outright (`return proxyInference(input.request)`). +The two versions differ in where the number comes from, and the shape does not change: 1.19.5 tries the inference proxy first and falls back to its own database when the proxy is unavailable (`.catch(inferenceUnavailable)`), while 2.0.32 forwards the request outright (`return proxyInference(input.request)`). The answer, in one line: **`GET https://opencode.ai/zen/go/v1/usage`**, and it is the only such endpoint on the gateway — the Zen plane has no counterpart, because the Zen plan's credits are a console concept and the dashboard reads them with a cookie. diff --git a/scripts/verify-session-band.ts b/scripts/verify-session-band.ts index 2aa754ec..54ee0ac6 100644 --- a/scripts/verify-session-band.ts +++ b/scripts/verify-session-band.ts @@ -8,7 +8,7 @@ * revision exists because the first version of it "refuted" the claim by porting * the vendor's generator WITHOUT its `direction` step. * - * `packages/opencode/src/id/id.ts` at v1.19.4: + * `packages/opencode/src/id/id.ts` at v1.19.5: * * now = BigInt(Date.now()) * 4096n + BigInt(++counter) // counter resets每毫秒 * now = direction === "descending" ? ~now : now diff --git a/src/fetch-patch.ts b/src/fetch-patch.ts index e8689da4..3889facc 100644 --- a/src/fetch-patch.ts +++ b/src/fetch-patch.ts @@ -336,11 +336,11 @@ export const patchFetch = ( // 1. Session headers, as the version this plugin emulates sends them. // - // Read from the 1.x line's last release, v1.19.4, in + // Read from the 1.x line's last release, v1.19.5, in // `packages/opencode/src/session/llm/request.ts`: `x-opencode-session-id` // and `x-opencode-session` both carry `sessionID`, and the affinity pair // belongs to relays. 2.x splits the first of those into an affinity id; the - // plugin emulates 1.19.4, so it follows 1.19.4. + // plugin emulates 1.19.5, so it follows 1.19.5. // // Which ENDPOINT the request goes to is the deployment's, not this block's: // measured, either key serves a paid model on either endpoint - they are two @@ -359,7 +359,7 @@ export const patchFetch = ( ); headers.set("x-opencode-session-id", sessionVal); headers.set(SESSION_HEADER, sessionVal); - // The affinity pair belongs to the OTHER branch in 1.19.4: for a provider + // The affinity pair belongs to the OTHER branch in 1.19.5: for a provider // that does not start with `opencode` - a relay a deployment claimed by URL - // the client sends `x-session-affinity` and `X-Session-Id`; for an OpenCode // plane it sends `x-opencode-session` instead and nothing else. This plugin @@ -398,7 +398,7 @@ export const patchFetch = ( headers.set("x-opencode-client", config.originClient); // The message id, the one header of the captured set this block omitted. - // Read from v1.19.4's `request.ts`: `"x-opencode-request": input.user.id`, + // Read from v1.19.5's `request.ts`: `"x-opencode-request": input.user.id`, // in the same branch as the client and project headers. The console reads // it for per-request metrics and rewrites it to `x-opencode-request-id` // before the request reaches the inference service diff --git a/src/go-discovery.ts b/src/go-discovery.ts index cf2e56bd..2b0f3696 100644 --- a/src/go-discovery.ts +++ b/src/go-discovery.ts @@ -191,7 +191,7 @@ export const discoverGoConfig = ( * measured rather than assumed.** The public quota route is * `GET /zen/go/v1/usage` and there is no second one: the only route file in * either major version is - * `packages/console/app/src/routes/zen/go/v1/usage.ts` (checked in 1.19.4 and + * `packages/console/app/src/routes/zen/go/v1/usage.ts` (checked in 1.19.5 and * 2.0.32), and the inference proxy's path table * (`packages/console/app/src/lib/inference-proxy.ts`) lists * `"GET /zen/go/v1/usage": "/go/v1/usage"` with no `/inference/.../usage` diff --git a/src/identity.ts b/src/identity.ts index ac37ca34..bf261cad 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -48,7 +48,7 @@ export const inject: string[] = []; * `0.0.0--*` snapshots under the `dev`/`next`/`beta` tags; nothing * here claims it, and a claim that did would need its own constant. */ -export const OPENCODE_CLI_VERSION = "1.19.4"; +export const OPENCODE_CLI_VERSION = "1.19.5"; /** * The User-Agent the stream hook sends: the CLI's own shape, verbatim. diff --git a/src/utils/session-id.ts b/src/utils/session-id.ts index 1e6312ef..d6e46e23 100644 --- a/src/utils/session-id.ts +++ b/src/utils/session-id.ts @@ -72,7 +72,7 @@ export interface SessionDigest { * Derive an OpenCode session id from a digest. * * **The vendor mints sessions DESCENDING, and nothing else needs doing.** - * `packages/opencode/src/id/id.ts` at v1.19.4: + * `packages/opencode/src/id/id.ts` at v1.19.5: * * now = BigInt(Date.now()) * 4096n + BigInt(++counter) // per-millisecond * now = direction === "descending" ? ~now : now diff --git a/test/client-headers-affinity.test.ts b/test/client-headers-affinity.test.ts index 63d53133..34b9164a 100644 --- a/test/client-headers-affinity.test.ts +++ b/test/client-headers-affinity.test.ts @@ -2,7 +2,7 @@ * Test: Client Headers, Session Affinity, and Lineage Contracts. * * Validates: - * - 1.19.4 generation contracts: x-opencode-session-id and x-opencode-session, no affinity on OpenCode planes + * - 1.19.5 generation contracts: x-opencode-session-id and x-opencode-session, no affinity on OpenCode planes * - Relay providers: sends x-session-affinity and x-session-id pair * - Subagent parent session lineage: x-opencode-parent-session-id and x-parent-session-id * - Session header recovery: patchFetch automatically derives a valid ses_<...> ID when missing @@ -61,9 +61,9 @@ afterEach(() => { }); describe("Client Headers, Session Affinity & Lineage", () => { - it("injects 1.19.4 generation headers on OpenCode planes without affinity headers", async () => { + it("injects 1.19.5 generation headers on OpenCode planes without affinity headers", async () => { const client = new ClientSimulator({ - generation: "1.19.4", + generation: "1.19.5", project: "dsh-affinity-test", sessionId: "ses_eb1e9e706ffeCDEFGHIJKLMNOP", }); @@ -83,7 +83,7 @@ describe("Client Headers, Session Affinity & Lineage", () => { it("injects affinity pair for relay / non-OpenCode providers", async () => { const client = new ClientSimulator({ - generation: "1.19.4", + generation: "1.19.5", sessionId: "ses_eb1e9e706fferalay0000000000", }); @@ -170,7 +170,7 @@ describe("Client Headers, Session Affinity & Lineage", () => { const req = result.request!; expect(req.headers["x-opencode-client"]).toBe("web"); expect(req.headers["x-opencode-project"]).toBe("my-project-repo"); - // The turn's message id travels with the origin headers, as in 1.19.4's + // The turn's message id travels with the origin headers, as in 1.19.5's // `request.ts`, where `x-opencode-request` sits in the same branch as the // client and project headers. expect(req.headers["x-opencode-request"]).toBe( diff --git a/test/e2e/patched-fetch-headers.e2e.ts b/test/e2e/patched-fetch-headers.e2e.ts index d1916351..b3fa85fb 100644 --- a/test/e2e/patched-fetch-headers.e2e.ts +++ b/test/e2e/patched-fetch-headers.e2e.ts @@ -85,11 +85,11 @@ describe.skipIf(!ENABLED)("patched fetch over a real socket", () => { expect(response.status).toBe(200); // The session headers carry ONE value in the generation this plugin emulates. - // Read from the 1.x line's last release, v1.19.4, in + // Read from the 1.x line's last release, v1.19.5, in // `packages/opencode/src/session/llm/request.ts`: `x-opencode-session-id` and // `x-opencode-session` both carry `sessionID`. 2.x splits the second into an // affinity id, and this case asserted that for a round - from the wrong - // generation, while the plugin emulates 1.19.4. + // generation, while the plugin emulates 1.19.5. expect(headerOf(0, "x-opencode-session-id")).toBe(state.value); expect(headerOf(0, SESSION_HEADER)).toBe(state.value); expect(headerOf(0, SESSION_AFFINITY_HEADER)).toBeUndefined(); @@ -99,7 +99,7 @@ describe.skipIf(!ENABLED)("patched fetch over a real socket", () => { expect(headerOf(0, PARENT_SESSION_HEADER)).toBe("ses_parent00000"); expect(headerOf(0, PARENT_SESSION_ALT_HEADER)).toBe("ses_parent00000"); - // Origin identity, including the turn's message id — the header 1.19.4's + // Origin identity, including the turn's message id — the header 1.19.5's // `request.ts` sends beside the client and project headers. expect(headerOf(0, "x-opencode-client")).toBe("cli"); expect(headerOf(0, "x-opencode-project")).toBe("dsh-opencode"); diff --git a/test/fetch-patch.test.ts b/test/fetch-patch.test.ts index 190d0f65..b46ed87b 100644 --- a/test/fetch-patch.test.ts +++ b/test/fetch-patch.test.ts @@ -764,7 +764,7 @@ describe("patchFetch", () => { }); it("sends the affinity pair for a relay and not for an OpenCode plane", async () => { - // The same split 1.19.4 makes, read from + // The same split 1.19.5 makes, read from // `packages/opencode/src/session/llm/request.ts:190-202`: for a provider that // starts with `opencode` the client sends `x-opencode-session`; for anything // else - a relay a deployment claimed by URL - it sends `x-session-affinity` diff --git a/test/go-discovery.test.ts b/test/go-discovery.test.ts index 4026620b..185d90ad 100644 --- a/test/go-discovery.test.ts +++ b/test/go-discovery.test.ts @@ -505,7 +505,7 @@ describe("toGoBaseURL and resolveGoBaseURL", () => { // from a provider row is very often that family. Rewriting only `zen/v1` // left it untouched and the meter asked `…/inference/openai/v1/usage` — a // path that does not exist. Measured: the only public quota route in either - // 1.19.4 or 2.0.32 is `packages/console/app/src/routes/zen/go/v1/usage.ts`, + // 1.19.5 or 2.0.32 is `packages/console/app/src/routes/zen/go/v1/usage.ts`, // and the inference proxy table has no `/inference/.../usage` entry. expect(toGoBaseURL("https://opencode.ai/inference/openai/v1")).toBe( GO_BASE diff --git a/test/mock/testing-kit.ts b/test/mock/testing-kit.ts index 4d4f1126..3635432c 100644 --- a/test/mock/testing-kit.ts +++ b/test/mock/testing-kit.ts @@ -262,7 +262,7 @@ export const AuthSimulator = { /* Client Simulator */ /* -------------------------------------------------------------------------- */ -export type ClientGeneration = "1.19.4" | "2.x"; +export type ClientGeneration = "1.19.5" | "2.x"; export interface ClientSimulatorOptions { channel?: string; @@ -282,7 +282,7 @@ export class ClientSimulator { constructor(options: ClientSimulatorOptions = {}) { this.options = options; this.sessionId = options.sessionId ?? MOCK_SESSION; - this.generation = options.generation ?? "1.19.4"; + this.generation = options.generation ?? "1.19.5"; } /** From 4a663847a1eef4acc3cbb07c514733ce737b7369 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 03:39:40 +0000 Subject: [PATCH 10/20] fix: leave the Google thinking control to the host's pi The Google level wrapper re-implemented pi 1.3's own rule for which Google models take a thinking level, for older pi. That is the host's job (D26): the plugin now hands every Google turn to pi's own protocol and sends what pi builds. pi 1.3 and later name the Go plane's level-only model, so every npm install of either channel is unaffected. The rc desktop app's pi (1.1.0) does not, and sends a budget the gateway refuses. That is recorded as a HOST_LIMITATION row in the in-process characterization and the binary e2e, both detected from pi's own exported usesGoogleThinkingLevel, never a version; the mock and live cases that need the level skip by the same predicate. The READMEs list it under "Host limitations", beside the alpha save defect. The catalog's level rule moves to test/support/thinking-levels.ts, where the cases that pick a subject by it live. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- README.md | 5 + README.zh-CN.md | 5 + docs/protocol-routing-and-merge.md | 2 +- docs/redirect-planes.md | 4 +- src/cordis-context.ts | 7 - src/host/google-levels.ts | 215 ------------ src/host/host-modules.ts | 62 ---- src/host/plane-provider.ts | 23 +- src/utils/model-metadata.ts | 45 +-- src/utils/plane-profile.ts | 30 -- test/e2e/host-binary.e2e.ts | 96 +++++- test/e2e/protocol-routing.e2e.ts | 34 +- test/google-levels.test.ts | 535 ----------------------------- test/host-characterization.test.ts | 40 ++- test/host-modules.test.ts | 119 ------- test/mock/plane-dispatch.mock.ts | 44 ++- test/model-metadata.test.ts | 142 -------- test/plane-profile.test.ts | 107 ------ test/plane-provider.test.ts | 70 ---- test/support/host.ts | 45 +-- test/support/src.ts | 9 +- test/support/thinking-levels.ts | 55 +++ test/thinking-levels.test.ts | 172 ++++++++++ vite.config.ts | 8 - 26 files changed, 428 insertions(+), 1450 deletions(-) delete mode 100644 src/host/google-levels.ts delete mode 100644 test/google-levels.test.ts create mode 100644 test/support/thinking-levels.ts create mode 100644 test/thinking-levels.test.ts diff --git a/AGENTS.md b/AGENTS.md index 95815b19..dc4f7ead 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ 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. **One lookup, and it is the tested one**: `attachAllowance` in `usage.ts` used to inline the same expression, and the copy nothing called was the tested one; it now goes through `allowanceFor`. The row's label is the model the allowance belongs to — the picked one, by the catalog's spelling — because a figure beside another model's name states something false about the money. - `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/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/host/route-claim.ts` · `src/host/plane-provider.ts` · `src/host/describe-graft.ts` · `src/host/host-modules.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. What a protocol sends, Google's thinking control included, is the host's pi's own (D26): where a host's pi lacks something the gateway needs, the suite records a `[HOST_LIMITATION]` row and skips the cases that would exercise it by pi's own exported predicate, never a version compare. - `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 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`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43d1a82a..dbd60d2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,7 +137,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Raise them when you genuinely add coverage** — a PR that lifts statements by a few points should lift the threshold with it, or the next contributor inherits a gate nobody re-measured. - **Never set one above the current measurement.** That does not make the suite better; it makes every subsequent PR fail until someone deletes tests. -`src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. Load-bearing modules carry their own per-file floors, so a well-covered average cannot hide one that stopped being exercised: `host/patch-method.ts`, 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. +`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`) at 100%, because each branch is a reload case, a protocol, a precedence or one of pi-ai's checks; `host/route-claim.ts`, `host/plane-provider.ts`, `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 1a3899d8..0b9c4220 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,11 @@ The harness publishes two **channels**, `alpha` and `rc`, and they are parallel The first clause covers the `0.2.1` line on both channels: its lower bound (`alpha.1`) sorts below every `rc` of the same line, so rc needs no separate entry. The second is **not** redundant — semver excludes prereleases unless a comparator in the same `major.minor.patch` tuple carries one, so `<0.3.0` alone would silently drop every `0.2.2-*` build, alpha and rc alike. Both clauses stop at `0.3.0`, which leaves the next line a separate decision rather than a silent widening. +**Host limitations.** What a supported host lacks today, recorded by the test suite as `[HOST_LIMITATION]` rows (detected from the host's own answer, never from a version) and reported upstream: + +- **Gemini 4 thinking levels on the `rc` desktop app.** Its pi (`1.1.0`) does not name `gemini-4-argon` among the models it sends Google's thinking _level_, so a Go Gemini 4 turn carries a thinking _budget_, which the gateway refuses (400 "Thinking budget is not supported for this model"). pi `1.3.0` and later send the level, so every npm install of either channel, and the `alpha` desktop app, are unaffected. Bumping pi is the host's fix; the plugin sends what the host's pi builds. +- **Saving settings on `alpha` `0.2.2-alpha.2` as npm installs it.** npm resolves cordis `4.0.6-alpha.2`, which calls the host's own `internal/update` listener in a new shape, so every config-editor save throws inside `dsh-app-boot` (`next is not a function`), whoever makes it. The `alpha` desktop app, on the cordis the harness vendors, saves. + | Surface | Target | | :-- | :-- | | **Harness line** | **`0.2.1`** and the **`0.2.2`** prereleases, `alpha` and `rc` channels alike (peer: the range above) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 627746d0..47174184 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -435,6 +435,11 @@ Harness 按 `alpha` 与 `rc` 两个**通道**发布,它们是平行的发布 第一个子句覆盖两个通道上的 `0.2.1` 整条线:下界 `alpha.1` 排序低于同线的所有 `rc`,因此无需单独枚举 rc。第二个子句**并不多余**——除非同一 `major.minor.patch` 的某个比较符带有预发布号,semver 会把预发布版本排除在外,所以只写 `<0.3.0` 会悄悄丢掉所有 `0.2.2-*` 构建,alpha 与 rc 都一样。两个子句都止于 `0.3.0`,下一条线因此是一个单独的决定,而不是悄悄放宽。 +**宿主限制。** 受支持的宿主目前缺少的能力,由测试套件记录为 `[HOST_LIMITATION]` 行(依据宿主自身的应答检测,从不依据版本号),并报告给上游: + +- **`rc` 桌面应用上的 Gemini 4 思考档位。** 其 pi(`1.1.0`)不把 `gemini-4-argon` 列为以 Google 思考**档位**发送的模型,因此 Go 平面的 Gemini 4 回合携带的是思考**预算**,网关会拒绝(400 "Thinking budget is not supported for this model")。pi `1.3.0` 及以上发送的是档位,所以两个通道的所有 npm 安装以及 `alpha` 桌面应用都不受影响。升级 pi 是宿主的修复方式;插件发送的是宿主 pi 构造的请求。 +- **npm 安装的 `alpha` `0.2.2-alpha.2` 上保存设置。** npm 解析出的 cordis `4.0.6-alpha.2` 以新的调用形态调用宿主自身的 `internal/update` 监听器,因此无论谁发起,每次配置编辑器保存都会在 `dsh-app-boot` 内抛出(`next is not a function`)。`alpha` 桌面应用使用 harness 自带的 cordis,可以保存。 + | 层面 | 目标 | | :-- | :-- | | **Harness 版本线** | **`0.2.1`** 与 **`0.2.2`** 预发布版本,`alpha` 与 `rc` 通道均可(peer:见上方范围) | diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index 7f224926..20ed5ffd 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -136,7 +136,7 @@ What belongs here is only the part that is a routing consequence: the meter is a | Concern | Module | | :-- | :-- | | 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` | +| Serving the planes | `host/route-claim.ts`, `host/plane-provider.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 22fd75c6..98330f52 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -189,6 +189,6 @@ What carried over from the bridge, and why: - **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. +- **A Gemini model's thinking control is pi's own (D26).** Google has two thinking controls and a model accepts only its own: a level model 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 decides which control a request carries by its own rule (`usesGoogleThinkingLevel`), which names Gemini 4 from pi `1.3.0` on; the plugin sends what the host's pi builds and re-derives nothing. pi `1.1.0`, the `rc` channel's floor and its desktop app's pi, does not name it, so a Go Gemini 4 turn there carries a budget: a `[HOST_LIMITATION]` row (`test/host-characterization.test.ts`, and the binary e2e's floor cell), reported upstream, with the mock and live cases that need the level skipping by pi's own predicate. A profile that declares Off for a level model gets pi's own disable, `thinkingBudget: 0`; a model that cannot turn thinking off (`gemini-4-argon`) refuses it, so declaring Off there is a profile error. -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`. +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` wherever the host's pi names the model (the `[HOST_LIMITATION]` row above elsewhere). diff --git a/src/cordis-context.ts b/src/cordis-context.ts index a595994f..7194505d 100644 --- a/src/cordis-context.ts +++ b/src/cordis-context.ts @@ -12,7 +12,6 @@ */ 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"; @@ -416,12 +415,6 @@ export interface HostLibraries { 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`. */ diff --git a/src/host/google-levels.ts b/src/host/google-levels.ts deleted file mode 100644 index 7b5ee67f..00000000 --- a/src/host/google-levels.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * 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 index efe0ffcc..e1fcf949 100644 --- a/src/host/host-modules.ts +++ b/src/host/host-modules.ts @@ -31,7 +31,6 @@ import type { } 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"; @@ -285,13 +284,9 @@ const PI_MODULES = { 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; @@ -350,59 +345,6 @@ const authOf = ( } }; -/** - * 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. @@ -436,7 +378,6 @@ export const loadLibrariesFrom = async ( go, google, headers, - lazy, models, responses, zen, @@ -449,7 +390,6 @@ export const loadLibrariesFrom = async ( importRecord(piFile("go")), importRecord(piFile("google")), importRecord(piFile("headers")), - importRecord(piFile("lazy")), importRecord(piFile("models")), importRecord(piFile("responses")), importRecord(piFile("zen")), @@ -484,7 +424,6 @@ export const loadLibrariesFrom = async ( 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( @@ -527,7 +466,6 @@ export const loadLibrariesFrom = async ( ...(interpolate === undefined ? {} : { interpolate: interpolate as HostLibraries["interpolate"] }), - ...(googleLevels === undefined ? {} : { googleLevels }), }; /* oxlint-enable typescript/no-unsafe-type-assertion */ }; diff --git a/src/host/plane-provider.ts b/src/host/plane-provider.ts index d5292a49..e430c66a 100644 --- a/src/host/plane-provider.ts +++ b/src/host/plane-provider.ts @@ -32,7 +32,6 @@ 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 { @@ -544,31 +543,21 @@ export const planeApiKeyResolver = /** * 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). + * a profile naming no key keeps pi's ambient discovery. Every protocol is + * wired on both planes: the plugin routes each catalog model to the protocol + * its SDK names (D16), Google on Go included, whatever pi's own Go provider + * lists. What a protocol sends is pi's, thinking controls included (D26). * * @param libraries - the host's pi. */ export const buildPlaneProvider = (libraries: HostLibraries): PlaneProviderBuilder => - ({ id, levelThinkingModels = new Set(), models, name }) => + ({ id, 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 - ), - ]; + return [protocol, libraries.withOpenCodeSessionHeader(api)]; }) ), auth: diff --git a/src/utils/model-metadata.ts b/src/utils/model-metadata.ts index c86bd7af..4f626f9e 100644 --- a/src/utils/model-metadata.ts +++ b/src/utils/model-metadata.ts @@ -22,7 +22,7 @@ * @module dsh-opencode-patch/utils/model-metadata */ -import { type Protocol, protocolForSdk } from "./protocols.ts"; +import type { Protocol } from "./protocols.ts"; /** pi's thinking levels, in escalation order, `off` first. */ export const PI_LEVELS = [ @@ -104,49 +104,6 @@ const effortValuesOf = (spec: ReasoningSpec): readonly string[] | undefined => (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"]; diff --git a/src/utils/plane-profile.ts b/src/utils/plane-profile.ts index 4071f47d..9bfbc750 100644 --- a/src/utils/plane-profile.ts +++ b/src/utils/plane-profile.ts @@ -20,7 +20,6 @@ */ import { isRecord } from "./guards.ts"; -import { usesCatalogThinkingLevels } from "./model-metadata.ts"; import { type EndpointChoice, type PlaneCatalogSpec, @@ -93,11 +92,6 @@ 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. */ @@ -272,29 +266,6 @@ const setCompatFields = (compat: unknown): string[] => .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; @@ -402,7 +373,6 @@ export const planeProfileFor = ( ); piProvider = input.buildProvider({ id: route, - levelThinkingModels: levelThinkingModelsOf(served, input.catalog), models: served, name: displayName, }); diff --git a/test/e2e/host-binary.e2e.ts b/test/e2e/host-binary.e2e.ts index e344fa44..86333600 100644 --- a/test/e2e/host-binary.e2e.ts +++ b/test/e2e/host-binary.e2e.ts @@ -46,13 +46,19 @@ * checks it. Today that leaves it out of alpha's `npm` cell, where cordis * floats to a newer patch. * - * Only the `npm` cell lets cordis float, as the published host's `~` ranges - * do for a user. Where what it resolved leaves the host unable to run its own - * save path, the run records a `[HOST_LIMITATION]` row and skips the save - * rows. That is detected from what the host answered, never from a version: - * the plane edit AND a control edit no plugin's listener reads (the probe's - * own entry) both fail with a `TypeError` thrown by `dsh-app-boot`. Today - * that is alpha's `0.2.2-alpha.2` as npm installs it (cordis `4.0.6-alpha.2`). + * Two host limitations are recorded as `[HOST_LIMITATION]` rows, each detected + * from what the host itself answers, never from a version (D26): + * + * - Only the `npm` cell lets cordis float, as the published host's `~` ranges + * do for a user. Where what it resolved leaves the host unable to run its + * own save path, the save rows skip: the plane edit AND a control edit no + * plugin's listener reads (the probe's own entry) both fail with a + * `TypeError` thrown by `dsh-app-boot`. Today that is alpha's + * `0.2.2-alpha.2` as npm installs it (cordis `4.0.6-alpha.2`). + * - Google thinking levels are pi's to send. A host whose pi's own + * exported predicate (`usesGoogleThinkingLevel`, `api/google-shared`) does + * not name the Go plane's level-only Google model sends it a budget, which the gateway + * refuses: today the rc channel's floor, pi 1.1.0, the desktop app's pi. * * `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. @@ -75,7 +81,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative, sep } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -122,6 +128,41 @@ const ROWS_PI = CELL === "floor" ? RECORD.piFloor : RECORD.pi; let rowsHold = false; /** The cordis the prefix resolved, for the HOST_LIMITATION row's message. */ let cordisVersion = ""; +/** The Go plane's Google model the gateway serves by thinking level only (the measured case). */ +const GEMINI_LEVEL_ID = "gemini-4-argon"; +/** + * Whether the prefix's own pi sends {@link GEMINI_LEVEL_ID} by thinking level; + * set in `beforeAll` by {@link piNamesLevelModel}. + */ +let piSendsArgonLevels = false; + +/** + * Whether the prefix's pi sends `id` by Google thinking level, asked of pi's + * own exported rule (`usesGoogleThinkingLevel`, `api/google-shared`, through + * pi's `./api/*` export): the feature detection D26 asks for, never a version + * compare. + * + * @param piRoot - the prefix's pi package root. + * @param id - the model id. + * @returns pi's own answer, or `false` when pi exports no such rule. + */ +const piNamesLevelModel = async ( + piRoot: string, + id: string +): Promise => { + const manifest = JSON.parse( + readFileSync(join(piRoot, "package.json"), "utf8") + ) as { exports?: Record }; + const target = manifest.exports?.["./api/*"]; + const pattern = typeof target === "string" ? target : target?.import; + if (pattern === undefined) { + return false; + } + const shared = (await import( + pathToFileURL(join(piRoot, pattern.replace("*", "google-shared"))).href + )) as { usesGoogleThinkingLevel?: (model: { id: string }) => boolean }; + return shared.usesGoogleThinkingLevel?.({ id }) === true; +}; /** * Whether `version` satisfies a caret range `^x.y.z` (x >= 1): the same @@ -380,6 +421,7 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { expect(dshVersion, "the prefix holds another channel's host").toBe( RECORD.dsh ); + piSendsArgonLevels = await piNamesLevelModel(piRoot, GEMINI_LEVEL_ID); expect(piCopies(join(PREFIX, "node_modules"))).toHaveLength(1); expect( satisfiesCaret(piVersion, RECORD.piRange), @@ -652,24 +694,44 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { 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.dispatch[`opencode-go/${GEMINI_LEVEL_ID}`]?.sent).toEqual([ + `https://opencode.ai/inference/go/google/v1beta/models/${GEMINI_LEVEL_ID}:streamGenerateContent?alt=sse`, ]); - expect(out.callConfig["opencode-go/gemini-4-argon"]).toBe("ok"); + expect(out.callConfig[`opencode-go/${GEMINI_LEVEL_ID}`]).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"] ?? []; + // The probe's turn chooses no effort, so a model pi sends by level is sent + // the level pi falls back to, never a budget. Which ids pi sends by level is + // pi's own rule (`usesGoogleThinkingLevel`); 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, where the host's pi names it", (context) => { + if (!piSendsArgonLevels) { + context.skip(); + } + const sent = out.thinking?.[`opencode-go/${GEMINI_LEVEL_ID}`] ?? []; expect(sent.length).toBeGreaterThan(0); for (const config of sent) { expect(config).toEqual({ thinkingLevel: "LOW" }); } }); + it(`[HOST_LIMITATION] ${CHANNEL} ${RECORD.dsh} (${CELL} cell): the host's pi sends the Go Gemini level model a thinking budget (reported upstream, D26)`, (context) => { + if (piSendsArgonLevels) { + context.skip(); + } + // Recorded, not fixed here: pi's own pattern does not name the id, so pi + // builds a budget for it, which the gateway refuses. The plugin sends what + // the host's pi builds; bumping pi is the host's fix. + console.warn( + `[e2e-host] ${CHANNEL} · ${CELL}: HOST_LIMITATION — pi ${hostPiVersion} sends ${GEMINI_LEVEL_ID} a thinking budget, not a level` + ); + const sent = out.thinking?.[`opencode-go/${GEMINI_LEVEL_ID}`] ?? []; + expect(sent.length).toBeGreaterThan(0); + for (const config of sent) { + expect(config).not.toHaveProperty("thinkingLevel"); + } + }); + it("[BASELINE] the settings page reads every plane the profile declares", () => { expect(out.describe?.value.toSorted()).toEqual([ "deepseek", diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index 4a5de539..76bd05e7 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -112,8 +112,8 @@ import { findModelSpecOn, isServableSdk, planeModelFor, - usesCatalogThinkingLevels, } from "../support/src.ts"; +import { usesCatalogThinkingLevels } from "../support/thinking-levels.ts"; import { freeTierClosed, gatewayBusy } from "./verdict.ts"; const LIVE = process.env.OPENCODE_E2E === "1"; @@ -1045,7 +1045,7 @@ describe.skipIf(!LIVE)( ); it.for(ADAPTER_PLANES)( - "$route: a Google model the catalog describes by level completes a turn at its highest offered level (D16)", + "$route: a Google model the catalog describes by level completes a turn at its highest offered level, where the installed pi names it (D16, D26)", { timeout: ADAPTER_BUDGET_MS }, async (plane, context) => { context.skip( @@ -1057,20 +1057,28 @@ describe.skipIf(!LIVE)( 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) - ) + // Which control a level model is sent is pi's own rule + // (`usesGoogleThinkingLevel`), never the plugin's (D26). A level model + // the installed pi does not name is sent a budget the gateway refuses: + // a HOST_LIMITATION (pi 1.1.0, the rc floor), which skips by name. + const levelModels = plane.catalog.filter( + (spec) => + paidProtocolOf(spec) === "google-generative-ai" && + usesCatalogThinkingLevels(spec) + ); + const missed = levelModels.filter( + (spec) => !usesGoogleThinkingLevel({ id: spec.id } as never) + ); + const candidates = levelModels + .filter((spec) => !missed.includes(spec)) .slice(0, ADAPTER_CANDIDATES); + context.skip( + candidates.length === 0 && missed.length > 0, + `HOST_LIMITATION: the installed pi's own usesGoogleThinkingLevel names none of ${missed.map((spec) => spec.id).join(", ")}, so pi sends a thinking budget (D26)` + ); context.skip( candidates.length === 0, - "no paid Google model the catalog describes by level that pi's own pattern misses" + "no paid Google model the catalog describes by level" ); const tier = await mountTierA({ credentials: fakeCredentials({ diff --git a/test/google-levels.test.ts b/test/google-levels.test.ts deleted file mode 100644 index 68379ea9..00000000 --- a/test/google-levels.test.ts +++ /dev/null @@ -1,535 +0,0 @@ -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/host-characterization.test.ts b/test/host-characterization.test.ts index d77f0386..13d6ee28 100644 --- a/test/host-characterization.test.ts +++ b/test/host-characterization.test.ts @@ -45,9 +45,10 @@ import { readFileSync } from "node:fs"; import { readProfilePatches } from "@deepseek-ai/dsh-app-boot"; +import { usesGoogleThinkingLevel } from "@earendil-works/pi-ai/api/google-shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CHANNELS } from "./support/host-versions.ts"; +import { CHANNELS, HOST_VERSIONS } from "./support/host-versions.ts"; import { adaptersOf, type ApplyOrder, @@ -96,6 +97,16 @@ type RowKey = S0RowKey; /** The pi the install under test runs, for the titles. */ const PI = PI_OF_CELL[HOST_CELL]; +/** + * Whether the installed pi sends the Go plane's level-only Google model by + * thinking level, + * asked of pi's own exported rule: the feature the HOST_LIMITATION row below + * detects, never a version compare (D26). + */ +const PI_SENDS_ARGON_LEVELS = usesGoogleThinkingLevel({ + id: "gemini-4-argon", +} as never); + let tier: TierB | undefined; let wire: RecordingFetch; let restarts: ReturnType | undefined; @@ -683,6 +694,33 @@ describe( expect(drained.finish).toEqual({ code: "PI_AI_ERROR", kind: "error" }); }); + // Which Google thinking control a request carries is pi's own rule, never + // the plugin's (D26): the plugin hands the turn to pi's Google protocol + // and sends what pi builds. pi 1.3 and later name that model and send it a + // level; pi 1.1.0, the rc channel's floor and the desktop app's pi, does + // not, and sends a budget the gateway refuses (measured live, 400 + // "Thinking budget is not supported for this model"). Recorded, reported + // upstream, not fixed here. + it.runIf(PI_SENDS_ARGON_LEVELS)( + `[BASELINE] Go gemini-4-argon is sent a thinking level and no thinkingBudget: pi ${PI}'s own rule names it (D26)`, + async () => { + const { requests } = await sentBy("opencode-go", "gemini-4-argon"); + expect(requests.map((request) => request.thinkingConfig)).toEqual([ + { thinkingLevel: "LOW" }, + ]); + } + ); + + it.runIf(!PI_SENDS_ARGON_LEVELS)( + `[HOST_LIMITATION] ${HOST_VERSIONS.channel} ${HOST_VERSIONS.dsh} (${HOST_CELL}, pi ${PI}): pi's own rule does not name Go gemini-4-argon, so the turn carries a thinkingBudget the gateway refuses (reported upstream, D26)`, + async () => { + const { requests } = await sentBy("opencode-go", "gemini-4-argon"); + expect(requests).toHaveLength(1); + expect(requests[0]?.thinkingConfig).toHaveProperty("thinkingBudget"); + expect(requests[0]?.thinkingConfig).not.toHaveProperty("thinkingLevel"); + } + ); + 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; diff --git a/test/host-modules.test.ts b/test/host-modules.test.ts index 35b477ec..82b92349 100644 --- a/test/host-modules.test.ts +++ b/test/host-modules.test.ts @@ -243,125 +243,6 @@ describe("host-modules · the host's tree, found from the context's base URL", ( }); }); -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(); diff --git a/test/mock/plane-dispatch.mock.ts b/test/mock/plane-dispatch.mock.ts index 31a6f1e5..dffe6815 100644 --- a/test/mock/plane-dispatch.mock.ts +++ b/test/mock/plane-dispatch.mock.ts @@ -44,8 +44,8 @@ import { clearCapturedApiKeys, headerValueFor, OPENCODE_ZEN_CATALOG, - usesCatalogThinkingLevels, } from "../support/src.ts"; +import { usesCatalogThinkingLevels } from "../support/thinking-levels.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { GO_KEY, ZEN_KEY } from "./helpers.ts"; @@ -657,13 +657,25 @@ describe("plane dispatch · Go Gemini replay stays on one provider label", () => }); }); -describe("plane dispatch · Google thinking by level, never by budget (D16)", () => { +describe("plane dispatch · Google thinking by level, never by budget (D16, D26)", () => { // 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. + // thinking level instead."), measured live on Go gemini-4-argon. Which + // control a request carries is pi's own rule (`usesGoogleThinkingLevel`), + // never the plugin's (D26): pi 1.3 and later name that model, pi 1.1.0 (the + // rc channel's floor, the desktop app's pi) does not, and sends it a + // budget. That is a HOST_LIMITATION, recorded in + // test/host-characterization.test.ts; the cases that need pi to name the id + // ask the installed pi's own predicate and skip, naming the limitation, + // where it does not. + + /** Whether the installed pi sends the Go level-only model by level (its own rule). */ + const piSendsArgonLevels = usesGoogleThinkingLevel({ + id: "gemini-4-argon", + } as never); + const HOST_LIMITATION = + "HOST_LIMITATION: the installed pi's own usesGoogleThinkingLevel does not name gemini-4-argon, so pi sends it a thinking budget (pi 1.1.0, the rc floor; D26)"; /** The thinking config one Google request carried. */ const thinkingConfigOf = (body: unknown): unknown => @@ -747,9 +759,10 @@ describe("plane dispatch · Google thinking by level, never by budget (D16)", () }); }; - it.each(PLANES)( - "%s gemini-4-argon (catalog effort levels low/medium/high) sends thinkingLevel for each chosen level and for none, and never a thinkingBudget", - async (plane) => { + it.for(PLANES)( + "%s gemini-4-argon (catalog effort levels low/medium/high) sends thinkingLevel for each chosen level and for none, and never a thinkingBudget, where the installed pi names it", + async (plane, context) => { + context.skip(!piSendsArgonLevels, HOST_LIMITATION); const model = "gemini-4-argon"; const sent = await thinkingSent(plane, model, [ "high", @@ -784,14 +797,12 @@ describe("plane dispatch · Google thinking by level, never by budget (D16)", () } ); - it("a Gemini 3 id pi already serves by level keeps pi's own request, for a chosen level and for none", async () => { + it("a Gemini 3 id every supported pi serves by level gets pi's own level 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. + // describes by level whose id pi's own pattern (`usesGoogleThinkingLevel`, + // asked of the installed pi) also serves by level, on every supported pi. + // The case runs it over the real adapter and pi's real Google protocol, + // and pins that the plugin hands pi's request to the wire unchanged. const control = OPENCODE_ZEN_CATALOG.find( (spec) => spec.provider_npm === "@ai-sdk/google" && @@ -833,7 +844,8 @@ describe("plane dispatch · Google thinking by level, never by budget (D16)", () ]); }); - 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 () => { + it("a profile that declares Off for gemini-4-argon gets pi's own disable, thinkingBudget: 0, which the model refuses (a profile error), where the installed pi names it", async (context) => { + context.skip(!piSendsArgonLevels, HOST_LIMITATION); // 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 diff --git a/test/model-metadata.test.ts b/test/model-metadata.test.ts index 9ce786ed..41d1ca6c 100644 --- a/test/model-metadata.test.ts +++ b/test/model-metadata.test.ts @@ -21,7 +21,6 @@ import { piReasoningFromEfforts, protocolForSdk, reasoningFor, - usesCatalogThinkingLevels, } from "./support/src.ts"; const zen = (id: string): CatalogModelSpec => { @@ -262,144 +261,3 @@ describe("model-metadata · piReasoningFromEfforts (pi-ai's resolveModelReasonin ); }); }); - -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/plane-profile.test.ts b/test/plane-profile.test.ts index 2e1b0596..0cfcdae3 100644 --- a/test/plane-profile.test.ts +++ b/test/plane-profile.test.ts @@ -224,113 +224,6 @@ describe("plane-profile · the field mapping and pi-ai's defaults", () => { }); }); -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( diff --git a/test/plane-provider.test.ts b/test/plane-provider.test.ts index 533d4d21..d9155831 100644 --- a/test/plane-provider.test.ts +++ b/test/plane-provider.test.ts @@ -635,74 +635,4 @@ describe("plane-provider · buildPlaneProvider", () => { } } }); - - 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/support/host.ts b/test/support/host.ts index 1f624eb5..a5624610 100644 --- a/test/support/host.ts +++ b/test/support/host.ts @@ -104,13 +104,9 @@ 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 { 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"; @@ -120,8 +116,6 @@ import { type Channel, CHANNELS, HOST_VERSIONS } from "./host-versions.ts"; import { apply, Config, - type GoogleLevelPieces, - type GoogleLevelSource, type HostLibraries, type HostModuleSource, inject, @@ -318,6 +312,8 @@ export interface RecordedRequest { readonly model: string | undefined; /** Lower-cased names; a credential header's value is replaced by its length. */ readonly headers: Readonly>; + /** A Google body's `generationConfig.thinkingConfig`, when it carries one. */ + readonly thinkingConfig?: unknown; } /** The hosts the recording fetch answers; everything else is refused. */ @@ -416,10 +412,15 @@ export const recordingFetch = ( } const text = await bodyTextOf(input, init); let model: string | undefined; + let thinkingConfig: unknown; try { - const parsed: unknown = JSON.parse(text ?? ""); - const candidate = (parsed as { model?: unknown } | null)?.model; + const parsed = JSON.parse(text ?? "") as { + model?: unknown; + generationConfig?: { thinkingConfig?: unknown }; + } | null; + const candidate = parsed?.model; model = typeof candidate === "string" ? candidate : undefined; + thinkingConfig = parsed?.generationConfig?.thinkingConfig; } catch { model = undefined; } @@ -430,6 +431,7 @@ export const recordingFetch = ( ).toUpperCase(), model, url, + ...(thinkingConfig === undefined ? {} : { thinkingConfig }), }; requests.push(request); return respond(request); @@ -1179,30 +1181,6 @@ export const logLines = ( }; }; -/** - * 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 @@ -1225,7 +1203,6 @@ export const hostModulesForTests = (): HostModuleSource => { >, createProvider: createProvider as unknown as HostLibraries["createProvider"], - googleLevels: googleLevelsForTests(), interpolate: interpolate as HostLibraries["interpolate"], LlmError: LlmError as unknown as HostLibraries["LlmError"], opencodeAuth: opencodeProvider().auth, diff --git a/test/support/src.ts b/test/support/src.ts index 8866bf89..f74aff38 100644 --- a/test/support/src.ts +++ b/test/support/src.ts @@ -147,13 +147,6 @@ export { } 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, @@ -415,8 +408,8 @@ export { PI_LEVELS, piReasoningFromEfforts, reasoningFor, - usesCatalogThinkingLevels, } from "../../src/utils/model-metadata.ts"; +export type { ReasoningSpec } from "../../src/utils/model-metadata.ts"; // utils/plane-model.ts export { baseUrlFor, planeModelFor } from "../../src/utils/plane-model.ts"; diff --git a/test/support/thinking-levels.ts b/test/support/thinking-levels.ts new file mode 100644 index 00000000..7e58cffd --- /dev/null +++ b/test/support/thinking-levels.ts @@ -0,0 +1,55 @@ +/** + * Which catalog models the gateway serves by Google's discrete thinking LEVEL, + * read from the catalog, for the cases that pick a subject by it. + * + * Google has two thinking controls, and a model accepts only its own: a + * discrete `thinkingLevel`, or a token `thinkingBudget`. 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 the Go + * plane's level-only model. Which control a request carries is pi's to decide + * (`usesGoogleThinkingLevel` in `api/google-shared`), never the plugin's + * (D26): this rule only lets a test name the models whose request a pi that + * does not name them would build wrong, so the case can run where the + * installed pi names them and skip, as a `HOST_LIMITATION`, where it does not. + * + * Pure: a function of its argument. + * + * @module test/support/thinking-levels + */ +import { protocolForSdk, type ReasoningSpec } from "./src.ts"; + +/** 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 first + * effort list with values names only `minimal`/`low`/`medium`/`high`. A + * `budget_tokens` option beside it says a budget is ALSO accepted, which + * leaves a level request valid, so it does not change the answer. + * + * @param spec - the catalog spec. + * @returns whether the model thinks in levels. + */ +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 = spec.reasoning_options?.find( + (option) => option.type === "effort" && (option.values?.length ?? 0) > 0 + )?.values; + return ( + values !== undefined && + values.every((value) => GOOGLE_THINKING_LEVELS.has(value)) + ); +}; diff --git a/test/thinking-levels.test.ts b/test/thinking-levels.test.ts new file mode 100644 index 00000000..e313d4bf --- /dev/null +++ b/test/thinking-levels.test.ts @@ -0,0 +1,172 @@ +/** + * The test-side rule that names the catalog models the gateway serves by + * Google thinking level (`test/support/thinking-levels.ts`), held to the + * bundled catalog and to every effort shape the catalog uses. + * + * @module test/thinking-levels.test + */ +import { describe, expect, it } from "vitest"; + +import { + type CatalogModelSpec, + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, +} from "./support/src.ts"; +import { usesCatalogThinkingLevels } from "./support/thinking-levels.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; +}; + +describe("thinking-levels · usesCatalogThinkingLevels (which catalog models think in Google levels)", () => { + 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 own 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/vite.config.ts b/vite.config.ts index a1a17eba..b5f1c677 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -295,14 +295,6 @@ export default defineConfig({ 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, From 7ed93a795321865ce0337dce5412b869ef3d4d9c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 03:42:02 +0000 Subject: [PATCH 11/20] fix: route by the plugin's own endpoint table, never pi's catalog A model's protocol was the catalog SDK, then pi's installed entry; its legacy base took pi's own /zen base when pi filed the model there. pi's catalog is the host's to reshape, and 1.5.0 did (per-protocol roots, and refiled models), so a pi bump could move a turn the plugin routes. The protocol is now the catalog SDK, then the plane entry's route-level api, then completions (D16), and the base comes from the endpoint family table alone (D26). pi's entry still fills metadata the user's row and the catalog leave unstated (D17). A new case builds every catalog model on both planes and families twice, with pi's entry and with that entry reshaped, and holds both the protocol and the base still. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- README.md | 4 +- README.zh-CN.md | 4 +- docs/protocol-routing-and-merge.md | 2 +- src/utils/plane-model.ts | 53 +++++---- test/plane-model.test.ts | 172 ++++++++++++++++------------- 5 files changed, 123 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 0b9c4220..8e90f1e1 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Counts are the **81** active `opencode` models in `models.opencode.ai` as of 202 Four properties follow: -- **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. +- **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 the catalog does not classify is sent as Chat Completions, unless the plane's entry names a route-level `api`, which applies to exactly those ids. pi's own catalog never decides a protocol or a base, so a pi upgrade that reshapes it moves no turn. - **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. @@ -423,7 +423,7 @@ Disabling or removing the plugin hands both planes back to `llm-pi-ai`, with not - **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. +- **No per-model protocol pin.** `modelRoutes` could send one model on a protocol of your choosing; nothing replaces that. A model the catalog describes always speaks the protocol its SDK names; the plane entry's route-level `api` applies only to ids the catalog does not classify, 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 47174184..4c031501 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -170,7 +170,7 @@ Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在 由此有四条性质: -- **你的挑选仍然有效。** 每个平面服务的正是其 `llm-pi-ai` 条目列出的模型;条目没有列出任何模型时,服务整个目录。你保存的任何 id 都会被接受并服务:目录与 pi 都未描述的 id 按 Chat Completions 发送,除非平面条目指定了路由级的 `api`——它只作用于这类 id。 +- **你的挑选仍然有效。** 每个平面服务的正是其 `llm-pi-ai` 条目列出的模型;条目没有列出任何模型时,服务整个目录。你保存的任何 id 都会被接受并服务:目录未归类的 id 按 Chat Completions 发送,除非平面条目指定了路由级的 `api`——它只作用于这类 id。pi 自己的目录从不决定协议或地址,所以 pi 升级即使改写了它,也不会移动任何回合。 - **你的模型行优先。** 模型的名称、上下文窗口、输出上限与输入模态,先取你自己的模型行,再取目录,最后取 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` 失败。 @@ -417,7 +417,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 - **保存的任何 id 都会被服务。** 目录未描述的模型在保存时被接受,并按 Chat Completions 发送;过去它会在保存时被拒绝。 - **无密钥时的列表过滤已移除。** 密钥解析不到的平面会列出其声明的模型,由回合指出缺失的凭据,而不是隐藏付费模型。 - **`modelOverrides`、`maxContextWindow` 与 `modelRoutes` 已移除**(以及文档中写过、却从未被读取的 `hiddenModels`)。平面条目中模型自己的行可以写名称、上下文窗口、输出上限、输入模态与推理档位,其余由目录补齐;行里的 `contextWindow` 代替 `maxContextWindow`。仍带有这些已移除键的配置行可以通过校验,这些键会被忽略。 -- **不再能按模型固定协议。** `modelRoutes` 可以让某个模型走你指定的协议;这一点没有替代。目录或 pi 描述过的模型总是使用它们指定的协议;平面条目的路由级 `api` 只作用于两者都未描述的 id,`baseURL` 也只在路由级。价格同样不是模型行的字段:先取目录,再取 pi。 +- **不再能按模型固定协议。** `modelRoutes` 可以让某个模型走你指定的协议;这一点没有替代。目录描述过的模型总是使用其 SDK 指定的协议;平面条目的路由级 `api` 只作用于目录未归类的 id,`baseURL` 也只在路由级。价格同样不是模型行的字段:先取目录,再取 pi。 - **停用或重载插件会恢复它包装过的宿主方法。** 停用时模型发现与设置嫁接会被撤下,热重载会替换它们而不是再叠一层。两个平面交还给 `llm-pi-ai`——见[移除或停用插件](#移除或停用插件)。 - **`@viztor/dsh-opencode` 不再发布。** 其最后一个版本上的 "Renamed to @viztor/dsh-opencode-patch" 弃用提示即为最终信息;请改装 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。 diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index 20ed5ffd..1fd05356 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -36,7 +36,7 @@ Since P-R1 the plugin serves `opencode` and `opencode-go` itself, with **one ada }; ``` -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. +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 the plane entry's `api`, then completions (D16), never from pi's installed entry (D26); its base per protocol on the chosen endpoint family, from the plugin's own endpoint table and never from pi's catalog base, so a pi release that reshapes its catalog moves no turn; 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). diff --git a/src/utils/plane-model.ts b/src/utils/plane-model.ts index b21b6802..7db194de 100644 --- a/src/utils/plane-model.ts +++ b/src/utils/plane-model.ts @@ -4,15 +4,21 @@ * * | field | first source that has it | * | :-- | :-- | - * | protocol | the catalog SDK ({@link protocolForSdk}) > pi's entry > the route's `api` > completions (D16) | + * | protocol | the catalog SDK ({@link protocolForSdk}) > 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}) | + * | baseUrl | the route's `baseURL`, shaped > the endpoint family table ({@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. + * A route-level `api` never reclassifies an id the catalog describes: the + * plane serves every protocol at once, so the route's `api` only says what an + * id the catalog does not classify speaks. + * + * pi's own catalog entry is in neither the protocol nor the base (D16, D26): + * the endpoint family table, the vendor's endpoints, is the only base source, + * so a pi release that reshapes its catalog (1.5.0 moved its bases to + * per-protocol roots) cannot move a turn. pi's entry still fills metadata the + * user's row and the catalog leave unstated (D17). * * 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 @@ -176,8 +182,10 @@ const forProtocol = (protocol: Protocol, base: string): string => * 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. + * 5. Legacy: the fixed table — Go's Google cell included. + * + * pi's own catalog base is never read: a pi release that moves its bases + * cannot move a turn (D26). * * @returns the base URL. */ @@ -187,8 +195,7 @@ export const baseUrlFor = ( sdk: string | undefined, endpoint: EndpointChoice, familyApi: Readonly>, - routeBaseURL?: string, - pi?: PlanePiModel + routeBaseURL?: string ): string => { let family: "legacy" | "modern" = endpoint === "legacy" ? "legacy" : "modern"; if (routeBaseURL !== undefined && routeBaseURL.length > 0) { @@ -205,13 +212,7 @@ export const baseUrlFor = ( } } 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]; + return LEGACY_BASE[route][protocol]; } const declared = (sdk === undefined ? undefined : familyApi[sdk]) ?? @@ -288,10 +289,13 @@ const effortsDictOf = ( return dict; }; -/** The protocol an id speaks (D16). */ +/** + * The protocol an id speaks (D16): the catalog's SDK, then the route's `api`, + * then completions. pi's own catalog entry is not consulted (D26), so a pi + * release that refiles a model cannot move it. + */ const protocolOf = ( spec: PlaneCatalogSpec | undefined, - pi: PlanePiModel | undefined, routeApi: string | undefined ): Protocol => { const fromCatalog = @@ -299,13 +303,7 @@ const protocolOf = ( if (fromCatalog !== undefined) { return fromCatalog; } - if (isProtocol(pi?.api)) { - return pi.api; - } - if (spec === undefined && pi === undefined && isProtocol(routeApi)) { - return routeApi; - } - return "openai-completions"; + return isProtocol(routeApi) ? routeApi : "openai-completions"; }; /** @@ -325,7 +323,7 @@ export const planeModelFor = (input: PlaneModelInput): PlaneModelResult => { const fail = (detail: string): PlaneModelResult => ({ error: `llm-pi-ai: provider "${route}" model "${id}" ${detail}`, }); - const protocol = protocolOf(spec, pi, input.routeApi); + const protocol = protocolOf(spec, input.routeApi); const compat = compatFor( route, id, @@ -410,8 +408,7 @@ export const planeModelFor = (input: PlaneModelInput): PlaneModelResult => { spec?.provider_npm, input.endpoint, input.familyApi, - input.routeBaseURL, - pi + input.routeBaseURL ), contextWindow, cost: diff --git a/test/plane-model.test.ts b/test/plane-model.test.ts index eeb3660b..bc221148 100644 --- a/test/plane-model.test.ts +++ b/test/plane-model.test.ts @@ -2,8 +2,9 @@ * `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. + * The specs are the bundled catalog's and the pi entries the installed pi's + * own, so a row here is the model the plane adapter actually serves; pi's + * entry fills only metadata, never a protocol or a base (D26). * * @module test/plane-model.test */ @@ -66,7 +67,7 @@ const modelOf = (input: PlaneModelInput) => { }; 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", () => { + it("comes from the catalog SDK, then the route's api, then completions, never from pi's entry", () => { expect(modelOf(inputFor("opencode", "gpt-5.4")).api).toBe( "openai-responses" ); @@ -76,9 +77,9 @@ describe("plane-model · protocol (D16)", () => { 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-go", "gemini-4-argon")).api).toBe( + "google-generative-ai" + ); expect(modelOf(inputFor("opencode", "not-a-real-model")).api).toBe( "openai-completions" ); @@ -95,35 +96,87 @@ describe("plane-model · protocol (D16)", () => { 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. + // No spec, a pi entry: pi's protocol is not taken (D26); completions, + // or the route's api when it names one. + const piOnly = { + ...inputFor("opencode", "x"), + pi: { api: "anthropic-messages", id: "x" }, + }; + expect(modelOf(piOnly).api).toBe("openai-completions"); + expect(modelOf({ ...piOnly, routeApi: "openai-responses" }).api).toBe( + "openai-responses" + ); + // A route api the plane does not dispatch is not taken. expect( modelOf({ ...inputFor("opencode", "x"), - pi: { api: "mistral-conversations", id: "x" }, + routeApi: "mistral-conversations", }).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"); + // A spec whose SDK nothing maps falls through to the route's api, then + // completions — never to pi's entry. + const unmapped = { + ...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", + }, + }; + expect(modelOf(unmapped).api).toBe("openai-completions"); + expect(modelOf({ ...unmapped, routeApi: "anthropic-messages" }).api).toBe( + "anthropic-messages" + ); + }); + + it("a changed pi catalog entry moves neither a model's protocol nor its base, on either family (D26)", () => { + // pi 1.5.0 moved its catalog bases to per-protocol roots and refiled + // models; nothing the plugin routes may follow. Each declared model is + // built twice, once with pi's own entry and once with that entry + // reshaped (another protocol, another base), and must not move. + const reshaped = (pi: PlanePiModel | undefined, id: string) => ({ + ...pi, + api: + pi?.api === "anthropic-messages" + ? "openai-responses" + : "anthropic-messages", + baseUrl: "https://opencode.ai/somewhere/else/v1", + id, + }); + let compared = 0; + for (const plane of ["opencode", "opencode-go"] as const) { + const catalog = + plane === "opencode" ? OPENCODE_ZEN_CATALOG : OPENCODE_GO_CATALOG; + for (const spec of catalog) { + for (const endpoint of ["modern", "legacy"] as const) { + const own = planeModelFor(inputFor(plane, spec.id, { endpoint })); + const moved = planeModelFor({ + ...inputFor(plane, spec.id, { endpoint }), + pi: reshaped(piOf(plane, spec.id), spec.id), + }); + expect(moved.model?.api, `${plane}/${spec.id} ${endpoint}`).toBe( + own.model?.api + ); + expect(moved.model?.baseUrl, `${plane}/${spec.id} ${endpoint}`).toBe( + own.model?.baseUrl + ); + compared += 1; + } + } + } + // The walk is real: every catalog model on both planes, both families. + expect(compared).toBe( + 2 * (OPENCODE_ZEN_CATALOG.length + OPENCODE_GO_CATALOG.length) + ); + // And the reshaped entry really differs from what was served. + const served = modelOf(inputFor("opencode", "deepseek-v4-pro")); + expect(reshaped(piOf("opencode", "deepseek-v4-pro"), "x").baseUrl).not.toBe( + served.baseUrl + ); }); }); @@ -182,19 +235,14 @@ describe("plane-model · base (modern, legacy, route-level)", () => { ).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", () => { + it("legacy: the fixed table, whatever pi's own entry says, 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"); @@ -210,55 +258,21 @@ describe("plane-model · base (modern, legacy, route-level)", () => { 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"); + // A pi entry on a /zen root of its own, under the same protocol, is not + // taken either: the table is the only base source (D26). expect( - baseUrlFor( - "opencode", - "openai-responses", - undefined, - "legacy", - {}, - undefined, - { + modelOf({ + ...inputFor("opencode", "x", legacy), + pi: { api: "openai-completions", baseUrl: "https://opencode.ai/zen/beta/v1", id: "x", - } - ) + }, + }).baseUrl ).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"); + baseUrlFor("opencode", "openai-responses", undefined, "legacy", {}) + ).toBe("https://opencode.ai/zen/v1"); }); it("a route-level base: a vendor root selects its family, a foreign root is used as written (Anthropic without /v1)", () => { From 34d69596584f90d39018d3b5762b69dd218e8166 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 03:43:49 +0000 Subject: [PATCH 12/20] fix: size undescribed models with pi-ai's own defaults, not a copy The plane profile restated pi-ai's default context window and output tokens (262_144 / 32_768) as the fallback for an entry pi-ai's Config had not filled. The alpha bridge has since raised both (1_000_000 / 128_000): a copy that goes stale silently, and would understate a window wherever it was reached, the overflow trap D17 exists to avoid. pi-ai's own Config fills both into the entry the plugin reads back, with the channel's values, so the plugin now restates neither (D17, D26). Where no Config ran nothing fills them, and a model no source sizes is an error in pi-ai's words rather than an invented window. The characterization's catalog-unknown rows take each channel's value from one record (PI_AI_DEFAULTS), which host-shapes pins against the installed pi-ai, and the unit and e2e fixtures that fed the copy back in now pass stand-in values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- src/utils/plane-model.ts | 11 ++++++--- src/utils/plane-profile.ts | 24 ++++++++++++------- test/catalog.test.ts | 4 +++- test/e2e/dispatch-levels.e2e.ts | 4 +++- test/e2e/protocol-routing.e2e.ts | 4 +++- test/host-shapes.test.ts | 5 ++-- test/mock/protocol-compat.mock.ts | 6 +++-- test/plane-model.test.ts | 38 ++++++++++++++++++++++++++++--- test/plane-profile.test.ts | 36 +++++++++++++++++++++++++++++ 11 files changed, 113 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc4f7ead..31fb1361 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f - `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 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/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, then the route's `api`, and the base from the endpoint family table, never from pi's own catalog, D16, D26; metadata from the user's row first, D17), and the profile one plane entry becomes, with pi-ai's own checks and messages for every malformed field, never throwing; the default window and output tokens are the ones pi-ai's own `Config` fills in, never a copy (D17). - `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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbd60d2f..b006bd3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ The e2e files, one job each: `test/support/host.ts` builds the host from the packages this repository installs: the harness channel `test/support/host-versions.ts` selects (the `@deepseek-ai` cluster at that channel's published tag), with pi unpatched at the newest version the bridge's range admits, or at the range's floor after `scripts/use-channel.ts --floor`. The routing work is judged against it: the contract tests pin the host facts the design rests on that the host can answer in-process (`docs/improvement-plan.md`: F1, F2, F4, F5, F8, F10–F16, F18 and F20, each by number; F6 and F19's auth half (constructing a `PiAiAdapter`) are first exercised by P-R1; F19's export half is pinned in `host-shapes` (`recordKeyFor`); F17 is honoured, not pinned — tier B fixes the apply order and the binary e2e infers it; F9 is a fact about opencode's own client, outside this host), and the characterization rows record what today's plugin does. -**Host cells.** The rows are measured on three cells (`HostCell` in `host.ts`): `newest`, either channel's default install (pi 1.5.0 today, one pi for both channels, so one column; a fixture case fails the day the two records disagree), and `rc-floor` (pi 1.1.0) and `alpha-floor` (pi 1.3.0), the pis the desktop app ships. Where the cells differ, a row carries one expectation per cell (`forHost`, keyed by the cell's role, never by a pi version) or per channel (`forChannel`, for what the `@deepseek-ai` cluster decides, such as pi-ai's own defaults), and every tier file runs on whichever cell is installed. CI runs the unit suite, tiers A and B included, on both channels' `newest` install on every pull request and on both floors nightly and on dispatch; locally, `scripts/use-channel.ts` switches the install and the same run reads the cell from the marker it leaves. The cells differ today in pi's catalog bases (the legacy family on 1.1.0, the root `/inference(/go)/v1` on 1.3.0, per-protocol roots on 1.5.0, whose Zen catalog is net five models smaller than 1.3.0's), in which S0 ids pi can place (`PI_GAPS_BY_CELL`: 1.5.0 places both Go gap ids), in whether pi's Go provider carries Google (F10: not on 1.1.0), and in pi-ai's own defaults (the alpha cluster raised them to 1_000_000 / 128_000). +**Host cells.** The rows are measured on three cells (`HostCell` in `host.ts`): `newest`, either channel's default install (pi 1.5.0 today, one pi for both channels, so one column; a fixture case fails the day the two records disagree), and `rc-floor` (pi 1.1.0) and `alpha-floor` (pi 1.3.0), the pis the desktop app ships. Where the cells differ, a row carries one expectation per cell (`forHost`, keyed by the cell's role, never by a pi version) or per channel (`forChannel`, for what the `@deepseek-ai` cluster decides, such as pi-ai's own defaults), and every tier file runs on whichever cell is installed. CI runs the unit suite, tiers A and B included, on both channels' `newest` install on every pull request and on both floors nightly and on dispatch; locally, `scripts/use-channel.ts` switches the install and the same run reads the cell from the marker it leaves. The cells differ today in pi's catalog bases (the legacy family on 1.1.0, the root `/inference(/go)/v1` on 1.3.0, per-protocol roots on 1.5.0, whose Zen catalog is net five models smaller than 1.3.0's), in which S0 ids pi can place (`PI_GAPS_BY_CELL`: 1.5.0 places both Go gap ids), in whether pi's Go provider carries Google (F10: not on 1.1.0), and in pi-ai's own defaults (the alpha cluster raised them to 1_000_000 / 128_000). Since P-R1 none of that moves what the plugin routes: it builds every model's protocol and base from its own catalog and endpoint table, so a characterization row differs by cell only where the host itself decides — pi-ai's defaults for an id no catalog describes (the plugin restates none), and the Google thinking control pi builds, which pi 1.1.0 lacks for the Go plane's level-only model (a `[HOST_LIMITATION]` row). Two tiers, both in-process: diff --git a/src/utils/plane-model.ts b/src/utils/plane-model.ts index 7db194de..2323aed9 100644 --- a/src/utils/plane-model.ts +++ b/src/utils/plane-model.ts @@ -5,7 +5,7 @@ * | field | first source that has it | * | :-- | :-- | * | protocol | the catalog SDK ({@link protocolForSdk}) > the route's `api` > completions (D16) | - * | name, context, max tokens, modalities | the user's row > pi-ai's `modelOverrides` > catalog > pi > defaults (D17) | + * | name, context, max tokens, modalities | the user's row > pi-ai's `modelOverrides` > catalog > pi > pi-ai's own route defaults, as its `Config` filled them (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 table ({@link baseUrlFor}) | @@ -104,9 +104,14 @@ export interface PlaneModelInput { readonly endpoint: EndpointChoice; /** The catalog's family map for this plane (`OPENCODE_FAMILY_API.zen|go`). */ readonly familyApi: Readonly>; + /** + * The route's defaults, as pi-ai's `Config` filled them: the window and + * output tokens are the channel's own, and absent only where no `Config` + * ran, which leaves a model no source sizes an error (D17). + */ readonly defaults: { - readonly contextWindow: number; - readonly maxTokens: number; + readonly contextWindow?: number; + readonly maxTokens?: number; readonly input: readonly string[]; }; } diff --git a/src/utils/plane-profile.ts b/src/utils/plane-profile.ts index 9bfbc750..af57e50a 100644 --- a/src/utils/plane-profile.ts +++ b/src/utils/plane-profile.ts @@ -42,12 +42,17 @@ 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`). */ +/** + * pi-ai's defaults for the route-level settings the plugin validates before + * pi-ai's `Config` has filled them (`config.ts`). The default context window + * and output tokens are NOT here: they are the channel's (`dsh-llm-pi-ai` + * moved them between releases), and pi-ai's own `Config` fills them into the + * entry the plugin reads back (`defaultContextWindow`, `defaultMaxTokens`), so + * no copy here can go stale and understate a window (D17, D26). + */ const DEFAULTS = { - contextWindow: 262_144, input: ["text"], maxRequestImageBytes: 20 * 1024 * 1024, - maxTokens: 32_768, requestImageMaxBytes: 1024 * 1024, requestImagePixelBudget: 2048 * 2048, streamIdleTimeoutMs: 300_000, @@ -490,17 +495,20 @@ const resolveModels = ( const routeCompat = isRecord(source.compat) ? source.compat : undefined; const seen = new Set(); const models: Readonly>[] = []; + // pi-ai's `Config` (the `normalize` seam) fills the route's default window + // and output tokens with the channel's own values; the plugin states none. const defaults = { - contextWindow: numberOr( - source.defaultContextWindow, - DEFAULTS.contextWindow - ), + ...(typeof source.defaultContextWindow === "number" + ? { contextWindow: source.defaultContextWindow } + : {}), input: Array.isArray(source.defaultInput) ? source.defaultInput.filter( (item): item is string => typeof item === "string" ) : [...DEFAULTS.input], - maxTokens: numberOr(source.defaultMaxTokens, DEFAULTS.maxTokens), + ...(typeof source.defaultMaxTokens === "number" + ? { maxTokens: source.defaultMaxTokens } + : {}), }; for (const candidate of entries) { const entry: PlaneModelEntry = isRecord(candidate) ? candidate : {}; diff --git a/test/catalog.test.ts b/test/catalog.test.ts index e9110c84..ae029380 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -1190,7 +1190,9 @@ const servedOn = ( 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 }, + // Stand-ins for what pi-ai's `Config` fills; the routing read here + // ignores them, and the plugin restates none of them (D17). + defaults: { contextWindow: 123_456, input: ["text"], maxTokens: 6_543 }, endpoint: "modern", entry: { id }, familyApi: diff --git a/test/e2e/dispatch-levels.e2e.ts b/test/e2e/dispatch-levels.e2e.ts index 093d2d3c..d679c42b 100644 --- a/test/e2e/dispatch-levels.e2e.ts +++ b/test/e2e/dispatch-levels.e2e.ts @@ -91,7 +91,9 @@ const servedModel = ( spec: CatalogModelSpec ): Readonly> & { api: Protocol; baseUrl: string } => { const { model, error } = planeModelFor({ - defaults: { contextWindow: 262_144, input: ["text"], maxTokens: 32_768 }, + // Stand-ins for what pi-ai's `Config` fills; the routing read here + // ignores them, and the plugin restates none of them (D17). + defaults: { contextWindow: 123_456, input: ["text"], maxTokens: 6_543 }, endpoint: "modern", entry: { id: spec.id }, familyApi: plane.family, diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index 76bd05e7..bcec8995 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -228,7 +228,9 @@ const servedOn = ( return undefined; } return planeModelFor({ - defaults: { contextWindow: 262_144, input: ["text"], maxTokens: 32_768 }, + // Stand-ins for what pi-ai's `Config` fills; the routing read here + // ignores them, and the plugin restates none of them (D17). + defaults: { contextWindow: 123_456, input: ["text"], maxTokens: 6_543 }, endpoint: "modern", entry: { id: model }, familyApi: PLANE_ROUTE[plane].family, diff --git a/test/host-shapes.test.ts b/test/host-shapes.test.ts index 3450b379..ec029da6 100644 --- a/test/host-shapes.test.ts +++ b/test/host-shapes.test.ts @@ -38,6 +38,7 @@ import { forHost, HOST_CELL, mountTierA, + PI_AI_DEFAULTS, PI_GAPS, PI_OF_CELL, PLANES, @@ -298,9 +299,9 @@ describe("host · pi-ai's Config (llm-pi-ai/src/config.ts)", () => { // raised both (llm-pi-ai config.ts:65,68), the rc one keeps the old pair. expect(resolved).toEqual({ compat: { chatTemplateArgs: {}, chatTemplateKwargs: {} }, - defaultContextWindow: forChannel({ alpha: 1_000_000, rc: 262_144 }), + defaultContextWindow: forChannel(PI_AI_DEFAULTS).contextWindow, defaultInput: ["text"], - defaultMaxTokens: forChannel({ alpha: 128_000, rc: 32_768 }), + defaultMaxTokens: forChannel(PI_AI_DEFAULTS).maxTokens, headers: {}, maxRequestImageBytes: 20_971_520, modelOverrides: {}, diff --git a/test/mock/protocol-compat.mock.ts b/test/mock/protocol-compat.mock.ts index c0a6d1b2..338e9ac8 100644 --- a/test/mock/protocol-compat.mock.ts +++ b/test/mock/protocol-compat.mock.ts @@ -116,10 +116,12 @@ describe("plane × protocol × family", () => { sessionTurn({ model: spec.id, provider: plane }), async () => { const { model, error } = planeModelFor({ + // Stand-ins for what pi-ai's `Config` fills; the wire read here + // ignores them, and the plugin restates none of them (D17). defaults: { - contextWindow: 262_144, + contextWindow: 123_456, input: ["text"], - maxTokens: 32_768, + maxTokens: 6_543, }, endpoint: family, entry: { id: spec.id }, diff --git a/test/plane-model.test.ts b/test/plane-model.test.ts index bc221148..85de28e5 100644 --- a/test/plane-model.test.ts +++ b/test/plane-model.test.ts @@ -35,7 +35,12 @@ const piOf = (plane: Plane, id: string): PlanePiModel | undefined => (model) => model.id === id ); -const DEFAULTS = { contextWindow: 262_144, input: ["text"], maxTokens: 32_768 }; +/** + * The route's defaults as pi-ai's `Config` fills them. Stand-in values, on + * purpose not either channel's: the plugin restates none of pi-ai's defaults + * (D17), it reads back whatever the channel's `Config` filled in. + */ +const DEFAULTS = { contextWindow: 123_456, input: ["text"], maxTokens: 6_543 }; /** The input a plane builds for one declared id, with overrides. */ const inputFor = ( @@ -420,10 +425,10 @@ describe("plane-model · metadata (D17) and the model object", () => { }); const bare = modelOf(inputFor("opencode", "x")); expect(bare).toMatchObject({ - contextWindow: 262_144, + contextWindow: DEFAULTS.contextWindow, cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 }, input: ["text"], - maxTokens: 32_768, + maxTokens: DEFAULTS.maxTokens, name: "x", provider: "opencode", reasoning: false, @@ -485,6 +490,33 @@ describe("plane-model · metadata (D17) and the model object", () => { }); }); + it("restates none of pi-ai's defaults: a model no source sizes takes the route's filled defaults, and is an error where none was filled (D17)", () => { + const unsized = { + ...inputFor("opencode", "x"), + defaults: { input: ["text"] }, + }; + expect(planeModelFor(unsized).error).toBe( + 'llm-pi-ai: provider "opencode" model "x" contextWindow must be a positive integer' + ); + expect( + planeModelFor({ + ...unsized, + defaults: { contextWindow: 5, input: ["text"] }, + }).error + ).toBe( + 'llm-pi-ai: provider "opencode" model "x" maxTokens must be a positive integer' + ); + // A model the catalog sizes needs no default at all. + expect( + planeModelFor({ + ...inputFor("opencode", "gpt-5.4"), + defaults: { input: ["text"] }, + }).model?.contextWindow + ).toBe( + OPENCODE_ZEN_CATALOG.find((spec) => spec.id === "gpt-5.4")?.context_window + ); + }); + 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", { diff --git a/test/plane-profile.test.ts b/test/plane-profile.test.ts index 0cfcdae3..51fdd94f 100644 --- a/test/plane-profile.test.ts +++ b/test/plane-profile.test.ts @@ -128,6 +128,42 @@ describe("plane-profile · the field mapping and pi-ai's defaults", () => { expect(result.normalized).toMatchObject({ apiKeyEnv: "OPENCODE_API_KEY" }); }); + it("sizes a model no source describes with the window and output tokens pi-ai's own Config filled, and restates none of them (D17)", () => { + // What the installed channel's pi-ai fills, read back from its own Config, + // never from a copy here: the two channels' bridges differ. + const filled = normalize({}) as { + defaultContextWindow: number; + defaultMaxTokens: number; + }; + expect(filled.defaultContextWindow).toBeGreaterThan(0); + expect(filled.defaultMaxTokens).toBeGreaterThan(0); + const result = planeProfileFor( + input({ models: [{ id: "not-a-real-model" }] }) + ); + const [model] = ( + result.profile.piProvider as { + getModels: () => Record[]; + } + ).getModels(); + expect(model).toMatchObject({ + contextWindow: filled.defaultContextWindow, + id: "not-a-real-model", + maxTokens: filled.defaultMaxTokens, + }); + // Without pi-ai's Config nothing fills them, and the plugin invents none: + // the model is an error, in pi-ai's words, and the rest of the plane serves. + const bare = planeProfileFor( + input( + { models: [{ id: "not-a-real-model" }, { id: "gpt-5.4" }] }, + { normalize: undefined } + ) + ); + expect(bare.profile.modelErrors.get("not-a-real-model")).toBe( + 'llm-pi-ai: provider "opencode" model "not-a-real-model" contextWindow must be a positive integer' + ); + expect(served(bare)).toEqual(["gpt-5.4"]); + }); + 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); From fa53d8e1f89363ee33a6189701026ab37aa64dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 03:45:40 +0000 Subject: [PATCH 13/20] docs: cite both channels' host lines and correct the session header claim The route claim's note on F12 cited cordis's Fiber.update at the rc lines only; alpha.2 moved it (764-782). Every other host line the src cites was checked at both tags (rc.5 and alpha.2) and still holds. docs/redirect-planes.md said the plane provider puts all the gateway headers on every protocol. It adds only pi's own session header per call; the client, project and session-id headers come from the global fetch patch, which P10a moves into the call. The plan's P-R1 section records the restack: one row table for every host cell, and the three compensations for the host's pi removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- docs/improvement-plan.md | 3 ++- docs/redirect-planes.md | 2 +- src/host/route-claim.ts | 7 ++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index edba7b44..a4951c2c 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -400,8 +400,9 @@ 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.** 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. +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` took pi's own base when pi filed the model under the same protocol on a `/zen` root (withdrawn in the restack, D26: the family table is the only base source); 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. + - **Restacked onto both channels (D14'', D22'', D26).** Merged P-H, so it runs on either channel's newest pi and each floor. One row table serves every host cell, since the plugin places, describes and routes every declared id itself; a row differs by cell only where the host decides (pi-ai's own defaults for the catalog-unknown id, per channel). Three removals of compensation for the host's pi: the Gemini level wrapper (`src/host/google-levels.ts`, its wiring, test, mock case, coverage floor and docs) goes, so the Google thinking control is pi's own; pi's catalog entry leaves the protocol order (catalog SDK, then the route's `api`, then completions) and the base (the family table only), so a pi that reshapes its catalog moves no turn; and `plane-profile.ts` restates none of pi-ai's default window and output tokens, reading back what pi-ai's `Config` filled. What the wrapper's removal exposes is recorded, not fixed: pi `1.1.0` (the `rc` floor, its desktop app's pi) sends the Go plane's level-only model a thinking budget, a `[HOST_LIMITATION]` row in the in-process characterization and the binary e2e's floor cell, with the mock and live cases that need the level skipping by pi's own `usesGoogleThinkingLevel`, the READMEs' "Host limitations" list, and an upstream report. 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. diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 98330f52..09f121bb 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -141,7 +141,7 @@ Measured 2026-10-10 by building one with pi's own protocol implementations and i **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. +**The session header reaches every protocol, and the rest of the gateway headers do not come from the provider.** `buildPlaneProvider` wraps each `api` with pi's own `withOpenCodeSessionHeader` before `createProvider`, and that is all it adds per call: pi's wrapper sets `x-opencode-session` from the turn's session id on all four protocols, and steps aside when the request already carries one. `x-opencode-client`, `x-opencode-project`, the derived `ses_` id and the User-Agent are set by the global fetch patch on the request the protocol sends, not on the call's options; moving them into each call (`options.headers`) is P10a's first move. **One detail left open:** the Google implementation sets its own `User-Agent` inside the protocol, so ours arrives appended rather than replacing it (`pi (darwin …), opencode/1.19.5 …`). Whether the gateway accepts the combined value or wants a single agent string is unverified. diff --git a/src/host/route-claim.ts b/src/host/route-claim.ts index 8cef75aa..a8f07842 100644 --- a/src/host/route-claim.ts +++ b/src/host/route-claim.ts @@ -23,12 +23,13 @@ * **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` + * the profile unless `noSave` is set (`cordis/src/fiber.ts:736-753` at + * `dsh-v0.2.1-rc.5`, `:764-782` at `dsh-v0.2.2-alpha.2`; + * `loader/src/index.ts:115-121` at both). 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 + * (`loader/src/config/entry.ts:106` at both tags) — 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 From db81e7e4ebf2585119e26ed8c5e7d751dc47f7e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 03:47:39 +0000 Subject: [PATCH 14/20] test: size the adapter's toggle case through pi-ai's own Config The case built a plane profile with no normalize, so its undeclared model was sized by the plugin's copy of pi-ai's defaults. With that copy gone the model is an error, as the profile case pins; the case now hands the entry to pi-ai's own Config, as the route claim does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- test/plane-provider.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/plane-provider.test.ts b/test/plane-provider.test.ts index d9155831..11585e2b 100644 --- a/test/plane-provider.test.ts +++ b/test/plane-provider.test.ts @@ -203,7 +203,7 @@ describe("plane-provider · the adapter", () => { }); 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 { libraries, piAi } = await modules(); const goProfile = planeProfileFor({ buildProvider: buildPlaneProvider(libraries), catalog: [], @@ -214,6 +214,14 @@ describe("plane-provider · the adapter", () => { ], }, familyApi: OPENCODE_FAMILY_API.go, + // pi-ai's own Config fills the route's default window and output + // tokens for a model no source sizes; the plugin restates neither. + normalize: (raw) => + ( + piAi.Config({ providers: { "opencode-go": raw } }) as { + providers: { get: () => Record }; + } + ).providers.get()["opencode-go"], piModels: [], resolveRetryPolicy: libraries.resolveRetryPolicy, route: "opencode-go", From 48776d254e617fc2de681bc2361ed47d79003e6e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 03:50:45 +0000 Subject: [PATCH 15/20] test: hold every remote codec's two parser doors to one parser Deleting the Google level wrapper removed a module held at 100%, and the global function floor (97.4%) fell to 97.37% with nothing newly uncovered. Rather than lower the ratchet, this covers the five wire codecs' create() doors, which no test reached: a runtime that builds a codec per call takes that door, so a codec whose two doors parse differently decodes one way in tests and another on the host. A mutated create() fails the case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- test/remote-contract.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/remote-contract.test.ts b/test/remote-contract.test.ts index b9ae5e65..d5bc14be 100644 --- a/test/remote-contract.test.ts +++ b/test/remote-contract.test.ts @@ -162,4 +162,34 @@ describe("remote contract · host and client agree", () => { const ids = patchRemote.descriptors.map((descriptor) => descriptor.id); expect(ids).toContain("dsh-opencode-patch#opencodeGoUsage/readAccount"); }); + + it("gives every wire codec one parser, whichever door the runtime takes", () => { + // A codec answers its parser twice: as `schema.parse`, and from `create()`. + // A runtime that builds a fresh codec per call takes the second door, so a + // codec whose two doors parse differently decodes one way in a test that + // reads the schema and another on the host. Every parameter and result + // codec the descriptors carry is checked, and the walk must see some. + interface Codec { + readonly create: () => { readonly parse: unknown }; + readonly schema: { readonly parse: unknown }; + readonly typeSymbol: string; + } + const codecs = new Set(); + for (const descriptor of patchRemote.descriptors) { + const { parameters = [], result } = descriptor as unknown as { + parameters?: readonly { codec?: Codec }[]; + result?: Codec; + }; + for (const codec of [result, ...parameters.map((p) => p.codec)]) { + if (codec !== undefined) { + codecs.add(codec); + } + } + } + expect(codecs.size).toBeGreaterThanOrEqual(5); + for (const codec of codecs) { + expect(codec.create().parse, codec.typeSymbol).toBe(codec.schema.parse); + expect(codec.typeSymbol).toMatch(/^dsh-opencode-patch#/u); + } + }); }); From 0a165d6b5e096bfadb823464898af46270a7fedb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 03:54:39 +0000 Subject: [PATCH 16/20] test: record the floor's budget refusal in the dispatch cases Run on the rc floor (pi 1.1.0), the mock suite's per-model dispatch cases sent the Go plane's level-only model the budget pi builds there, and the mock gateway refused it, so the nightly floor cell would fail on a host limitation rather than record it. Those cases now expect the gateway's budget refusal where pi's own usesGoogleThinkingLevel does not name the model, and keep asserting the path, the model and the key. Measured on both floors and both channels' newest installs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- test/mock/plane-dispatch.mock.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/mock/plane-dispatch.mock.ts b/test/mock/plane-dispatch.mock.ts index dffe6815..fcb9d3da 100644 --- a/test/mock/plane-dispatch.mock.ts +++ b/test/mock/plane-dispatch.mock.ts @@ -173,6 +173,16 @@ const finishOf = ( * gateway records a request before it judges it, so the path alone cannot * tell an accepted body from a refused one. */ +/** + * Whether the installed pi sends `model` a thinking budget the mock gateway + * refuses: the Go plane's level-only model on a pi whose own + * `usesGoogleThinkingLevel` does not name it (pi 1.1.0, the rc floor). That is + * a HOST_LIMITATION (D26), detected from pi's own predicate, never a version. + */ +const hostSendsBudgetTo = (model: string): boolean => + model === "gemini-4-argon" && + !usesGoogleThinkingLevel({ id: model } as never); + const expectAnswered = ( label: string, model: string, @@ -180,6 +190,15 @@ const expectAnswered = ( chunks: readonly Record[] ): void => { const kind = finishOf(chunks)?.reason?.kind; + if (hostSendsBudgetTo(model)) { + // HOST_LIMITATION: the turn still reached its path with its model and + // key (the caller asserts those); the gateway refused the budget pi built. + expect({ reject: request?.reject, status: request?.status }, label).toEqual( + { reject: "thinking_budget", status: 400 } + ); + expect(kind, label).toBe("error"); + return; + } if (model === UNKNOWN_ID) { expect({ reject: request?.reject, status: request?.status }, label).toEqual( { reject: "model_unknown", status: 404 } From 2701c888fa4d0435c58a0fea90dfb71744f59371 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 04:01:49 +0000 Subject: [PATCH 17/20] test: record where the route claim cannot engage on the alpha npm host Run on the published binaries, both rc cells and the alpha floor serve the planes through the route claim. On alpha's npm cell, npm resolves cordis 4.0.6-alpha.2, whose internal/config waterfall hands listeners the fiber and reads the config from next(), and the claim speaks the earlier contract, so pi-ai serves both planes there; the binary e2e failed on that cell. The probe now records which contract the host dispatched, and the e2e asserts the measured state as a KNOWN_DEFECT row and skips the rows that need the claim, by name. dsh 0.2.2-alpha.3 requires that cordis, so the alpha bump must adopt the contract (feature-detected) and flip the row; the READMEs, CONTRIBUTING and the plan say so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- CONTRIBUTING.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/improvement-plan.md | 2 +- test/e2e/host-binary.e2e.ts | 59 ++++++++++++++++++++++++++++++---- test/e2e/host/probe-plugin.mjs | 18 ++++++++++- 6 files changed, 74 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b006bd3f..f21e9c5d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ What every case does: `.github/workflows/ci.yml`'s `e2e-host` job installs each channel's published `@deepseek-ai/dsh` (its record's `dsh`) into a throwaway prefix with npm, the way a user installs it, 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_BY_CELL` records for its cell, which P-R1 flipped to the plugin owning both planes. - **Cells, per channel, pi unpatched in both.** `npm` is the published host exactly as npm resolves it, cordis and pi included, what an npm user runs, and asserts the `newest` rows while its pi is the channel record's; a pi published since the record still has to sit in the bridge's range, but the per-model rows (a newer pi's own entry can still move a model's metadata where the catalog states none) are skipped with a warning, and the nightly `host-drift` job names the new pi. 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 every cell, since the plugin owns both planes and none of them depends on which ids pi describes. `floor` is the tree the desktop app ships: npm `overrides` (`floorOverrides` in `host-versions.ts`) force the channel's whole cordis family (cordis, the loader, the include, group and timer plugins, cosmokit, schemastery) at the record's versions, which are the floors of the `~` ranges the published cluster declares (what the harness vendors at the tag), and pi with its telemetry package at the range's floor; the job and the e2e assert that npm applied each one, and the cell asserts every row of the channel's floor cell. In both cells the e2e also holds each cordis-family field to the floor of the range the installed published package declares for it, which no override rewrites, with one rule for cosmokit: its declarer is cordis, so it is checked only where the installed cordis is the record's, and elsewhere only the nightly drift report checks it (today that leaves it out of alpha's `npm` cell, where cordis floats to a newer patch). It is cached by channel, `dsh` and a hash of those overrides, so a cache filled before the record or its overrides moved is never reused; the rest of the tree is what npm resolved when the cache was filled. The installed `dsh` must be the channel record's, so a prefix of the wrong channel fails by name. The save rows skip only where the host itself cannot save (next bullet). -- **The host's own failures are recorded, not worked around.** Only the `npm` cell lets cordis float, as the published host's `~` ranges do for a user, and the job reports what it resolved. Where that leaves the host unable to run its own save path, the run records a `[HOST_LIMITATION]` row (titled with the channel and the `dsh`) and skips the save rows. It is detected from what the host answered, never from a version (D26): the plane edit and a control edit no plugin's listener reads (the probe's own entry) both fail with the same `TypeError` thrown by `@deepseek-ai/dsh-app-boot`; where the host saves, the row skips and a `[BASELINE]` row asserts the control was accepted. Today that is alpha's `0.2.2-alpha.2` as npm installs it: cordis `4.0.6-alpha.2` calls `internal/update` in a new shape while `dsh-app-boot` registers its listener in the old one, so every config-editor save throws `next is not a function`. The alpha floor, on the cordis the harness vendors at the tag, saves. +- **The host's own failures are recorded, not worked around.** Only the `npm` cell lets cordis float, as the published host's `~` ranges do for a user, and the job reports what it resolved. Where that leaves the host unable to run its own save path, the run records a `[HOST_LIMITATION]` row (titled with the channel and the `dsh`) and skips the save rows. It is detected from what the host answered, never from a version (D26): the plane edit and a control edit no plugin's listener reads (the probe's own entry) both fail with the same `TypeError` thrown by `@deepseek-ai/dsh-app-boot`; where the host saves, the row skips and a `[BASELINE]` row asserts the control was accepted. Today that is alpha's `0.2.2-alpha.2` as npm installs it: cordis `4.0.6-alpha.2` calls `internal/update` in a new shape while `dsh-app-boot` registers its listener in the old one, so every config-editor save throws `next is not a function`. The alpha floor, on the cordis the harness vendors at the tag, saves. The same cordis hands `internal/config` listeners the fiber and reads the config from `next()`, a contract the route claim does not speak yet: the probe records which contract the host dispatched (`configHook`), and where it is the new one a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes and the rows that need the claim skip by name. The phase that bumps alpha to `0.2.2-alpha.3`, which requires that cordis, adopts the contract (feature-detected) and flips the row. - **Triggers.** Dispatch and the nightly run take both cells of both channels. A pull request takes the `npm` cell of both channels, 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 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` (what the job installs), `scripts/host-drift.ts` (the family table the binary e2e asserts against the installed manifests), `.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 a channel's 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 channel's record from `test/support/host-versions.ts`, the same way the CI job does, so the snippet never names a version itself: diff --git a/README.md b/README.md index 8e90f1e1..d1a47733 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ The first clause covers the `0.2.1` line on both channels: its lower bound (`alp **Host limitations.** What a supported host lacks today, recorded by the test suite as `[HOST_LIMITATION]` rows (detected from the host's own answer, never from a version) and reported upstream: - **Gemini 4 thinking levels on the `rc` desktop app.** Its pi (`1.1.0`) does not name `gemini-4-argon` among the models it sends Google's thinking _level_, so a Go Gemini 4 turn carries a thinking _budget_, which the gateway refuses (400 "Thinking budget is not supported for this model"). pi `1.3.0` and later send the level, so every npm install of either channel, and the `alpha` desktop app, are unaffected. Bumping pi is the host's fix; the plugin sends what the host's pi builds. -- **Saving settings on `alpha` `0.2.2-alpha.2` as npm installs it.** npm resolves cordis `4.0.6-alpha.2`, which calls the host's own `internal/update` listener in a new shape, so every config-editor save throws inside `dsh-app-boot` (`next is not a function`), whoever makes it. The `alpha` desktop app, on the cordis the harness vendors, saves. +- **Saving settings on `alpha` `0.2.2-alpha.2` as npm installs it.** npm resolves cordis `4.0.6-alpha.2`, which calls the host's own `internal/update` listener in a new shape, so every config-editor save throws inside `dsh-app-boot` (`next is not a function`), whoever makes it. The `alpha` desktop app, on the cordis the harness vendors, saves. The same cordis also hands configuration hooks a new shape, which this plugin's plane routing does not speak yet (a known defect, recorded by the suite and fixed with the `0.2.2-alpha.3` bump that requires this cordis), so on that install both planes run on pi-ai's own routing. | Surface | Target | | :-- | :-- | diff --git a/README.zh-CN.md b/README.zh-CN.md index 4c031501..99231627 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -438,7 +438,7 @@ Harness 按 `alpha` 与 `rc` 两个**通道**发布,它们是平行的发布 **宿主限制。** 受支持的宿主目前缺少的能力,由测试套件记录为 `[HOST_LIMITATION]` 行(依据宿主自身的应答检测,从不依据版本号),并报告给上游: - **`rc` 桌面应用上的 Gemini 4 思考档位。** 其 pi(`1.1.0`)不把 `gemini-4-argon` 列为以 Google 思考**档位**发送的模型,因此 Go 平面的 Gemini 4 回合携带的是思考**预算**,网关会拒绝(400 "Thinking budget is not supported for this model")。pi `1.3.0` 及以上发送的是档位,所以两个通道的所有 npm 安装以及 `alpha` 桌面应用都不受影响。升级 pi 是宿主的修复方式;插件发送的是宿主 pi 构造的请求。 -- **npm 安装的 `alpha` `0.2.2-alpha.2` 上保存设置。** npm 解析出的 cordis `4.0.6-alpha.2` 以新的调用形态调用宿主自身的 `internal/update` 监听器,因此无论谁发起,每次配置编辑器保存都会在 `dsh-app-boot` 内抛出(`next is not a function`)。`alpha` 桌面应用使用 harness 自带的 cordis,可以保存。 +- **npm 安装的 `alpha` `0.2.2-alpha.2` 上保存设置。** npm 解析出的 cordis `4.0.6-alpha.2` 以新的调用形态调用宿主自身的 `internal/update` 监听器,因此无论谁发起,每次配置编辑器保存都会在 `dsh-app-boot` 内抛出(`next is not a function`)。`alpha` 桌面应用使用 harness 自带的 cordis,可以保存。同一个 cordis 还改变了配置钩子的调用形态,本插件的平面路由尚不支持这一形态(已知缺陷,由测试套件记录,随要求该 cordis 的 `0.2.2-alpha.3` 升级一并修复),因此在该安装上两个平面都运行在 pi-ai 自己的路由上。 | 层面 | 目标 | | :-- | :-- | diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index a4951c2c..d15b2dd1 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -402,7 +402,7 @@ What stays private is pinned by contract tests against the real host packages: - **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.** 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` took pi's own base when pi filed the model under the same protocol on a `/zen` root (withdrawn in the restack, D26: the family table is the only base source); 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. - - **Restacked onto both channels (D14'', D22'', D26).** Merged P-H, so it runs on either channel's newest pi and each floor. One row table serves every host cell, since the plugin places, describes and routes every declared id itself; a row differs by cell only where the host decides (pi-ai's own defaults for the catalog-unknown id, per channel). Three removals of compensation for the host's pi: the Gemini level wrapper (`src/host/google-levels.ts`, its wiring, test, mock case, coverage floor and docs) goes, so the Google thinking control is pi's own; pi's catalog entry leaves the protocol order (catalog SDK, then the route's `api`, then completions) and the base (the family table only), so a pi that reshapes its catalog moves no turn; and `plane-profile.ts` restates none of pi-ai's default window and output tokens, reading back what pi-ai's `Config` filled. What the wrapper's removal exposes is recorded, not fixed: pi `1.1.0` (the `rc` floor, its desktop app's pi) sends the Go plane's level-only model a thinking budget, a `[HOST_LIMITATION]` row in the in-process characterization and the binary e2e's floor cell, with the mock and live cases that need the level skipping by pi's own `usesGoogleThinkingLevel`, the READMEs' "Host limitations" list, and an upstream report. + - **Restacked onto both channels (D14'', D22'', D26).** Merged P-H, so it runs on either channel's newest pi and each floor. One row table serves every host cell, since the plugin places, describes and routes every declared id itself; a row differs by cell only where the host decides (pi-ai's own defaults for the catalog-unknown id, per channel). Three removals of compensation for the host's pi: the Gemini level wrapper (`src/host/google-levels.ts`, its wiring, test, mock case, coverage floor and docs) goes, so the Google thinking control is pi's own; pi's catalog entry leaves the protocol order (catalog SDK, then the route's `api`, then completions) and the base (the family table only), so a pi that reshapes its catalog moves no turn; and `plane-profile.ts` restates none of pi-ai's default window and output tokens, reading back what pi-ai's `Config` filled. What the wrapper's removal exposes is recorded, not fixed: pi `1.1.0` (the `rc` floor, its desktop app's pi) sends the Go plane's level-only model a thinking budget, a `[HOST_LIMITATION]` row in the in-process characterization and the binary e2e's floor cell, with the mock and live cases that need the level skipping by pi's own `usesGoogleThinkingLevel`, the READMEs' "Host limitations" list, and an upstream report. Measured on the published binaries: both cells of `rc` and the `alpha` floor serve the planes through the claim; on `alpha`'s npm cell cordis `4.0.6-alpha.2` hands `internal/config` listeners the fiber and reads the config from `next()`, a contract the claim does not speak, so pi-ai serves the planes there — a `[KNOWN_DEFECT]` row (detected from the contract the probe saw dispatched), which the alpha bump to `0.2.2-alpha.3` (requiring that cordis) must fix before it lands. 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. diff --git a/test/e2e/host-binary.e2e.ts b/test/e2e/host-binary.e2e.ts index 86333600..5e86aab1 100644 --- a/test/e2e/host-binary.e2e.ts +++ b/test/e2e/host-binary.e2e.ts @@ -233,6 +233,12 @@ interface ProbeOutput { { configured: boolean; apiKeyEnv?: string } >; readonly savedInFile?: boolean; + /** + * The contract the host's `internal/config` waterfall speaks, as one of its + * dispatches showed it (`null` when none was seen): `"config"` hands a + * listener the config, `"fiber"` hands it the fiber. + */ + readonly configHook: "config" | "fiber" | null; readonly saveBlocked: { id: string } & EditVerdict; /** The control: an edit of the probe's own entry, made after the saves. */ readonly control?: EditVerdict; @@ -278,6 +284,21 @@ const hostListenerFailed = (verdict: EditVerdict | undefined): boolean => * report it. Read from what the edits answered, never from a version, so a * host that fixes it reads false the day it ships (D26). */ +/** + * Whether the host's `internal/config` waterfall hands its listeners the fiber + * and reads the config from `next()`: cordis `4.0.6-alpha.2`'s contract, which + * npm resolves for `alpha` `0.2.2-alpha.2` today and which `0.2.2-alpha.3` + * requires. The route claim speaks the earlier contract (the config, with the + * fiber as `this`), so on such a host it does not engage and pi-ai serves the + * planes. Read from what the host dispatched, never from a version. + */ +const claimCannotEngage = (probe: ProbeOutput): boolean => + probe.configHook === "fiber"; + +/** The reason the claim-dependent rows skip on such a host. */ +const CLAIM_SKIP = + "KNOWN_DEFECT: the host's internal/config waterfall hands listeners the fiber (cordis 4.0.6-alpha.2), a contract the route claim does not speak yet"; + const hostCannotSave = (probe: ProbeOutput): boolean => hostListenerFailed(probe.saveBlocked) && hostListenerFailed(probe.control) && @@ -635,13 +656,32 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { } }); - it("measured with the probe innermost and both planes settled on the plugin's adapter", () => { + it("measured with the probe innermost and both planes settled on the plugin's adapter", (context) => { expect(out.fatal).toBeUndefined(); expect(out.fetchInnermost).toBe(true); + // Known answer for the detector below: some dispatch was observed. + expect(out.configHook).not.toBeNull(); + context.skip(claimCannotEngage(out), CLAIM_SKIP); expect(out.planesSettled).toBe(true); }); - it("the plane provider is built from the host's own pi, not the linked checkout's (both cells)", () => { + it(`[KNOWN_DEFECT] ${CHANNEL} ${RECORD.dsh} (${CELL} cell), on a host whose internal/config waterfall hands listeners the fiber: the route claim does not engage, and pi-ai serves both planes`, (context) => { + context.skip( + !claimCannotEngage(out), + "this host's internal/config waterfall hands listeners the config" + ); + // Recorded so the phase that bumps alpha to a release requiring this + // cordis adopts the contract (feature-detected) and flips this row. + console.warn( + `[e2e-host] ${CHANNEL} · ${CELL}: KNOWN_DEFECT — cordis ${cordisVersion} hands internal/config listeners the fiber; the route claim did not engage` + ); + expect(out.planesSettled).toBe(false); + expect(out.owners.opencode).toBe("PiAiAdapter"); + expect(out.owners["opencode-go"]).toBe("PiAiAdapter"); + }); + + it("the plane provider is built from the host's own pi, not the linked checkout's (both cells)", (context) => { + context.skip(claimCannotEngage(out), CLAIM_SKIP); // 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 @@ -653,7 +693,8 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { }); }); - 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)", () => { + 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)", (context) => { + context.skip(claimCannotEngage(out), CLAIM_SKIP); expect(out.providers).toEqual( expect.arrayContaining(["opencode", "opencode-go"]) ); @@ -671,11 +712,13 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { } }); - it("[BASELINE] listing before any turn: every declared id is listed (D21)", () => { + it("[BASELINE] listing before any turn: every declared id is listed (D21)", (context) => { + context.skip(claimCannotEngage(out), CLAIM_SKIP); expect(out.listModels).toEqual(ROWS.listingBeforeTurn); }); - it("[BASELINE] listing after a turn on each plane: every declared id is still listed (G1)", () => { + it("[BASELINE] listing after a turn on each plane: every declared id is still listed (G1)", (context) => { + context.skip(claimCannotEngage(out), CLAIM_SKIP); expect(out.listModelsAfter).toEqual(ROWS.listingAfterTurn); }); @@ -686,6 +729,7 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { if (!rowsHold) { context.skip(); } + context.skip(claimCannotEngage(out), CLAIM_SKIP); for (const [key, row] of Object.entries(ROWS.rows)) { expect(out.modelInfo[key], key).toEqual(row.info); expect(out.callConfig[key], key).toBe(row.callConfig); @@ -708,6 +752,7 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { if (!piSendsArgonLevels) { context.skip(); } + context.skip(claimCannotEngage(out), CLAIM_SKIP); const sent = out.thinking?.[`opencode-go/${GEMINI_LEVEL_ID}`] ?? []; expect(sent.length).toBeGreaterThan(0); for (const config of sent) { @@ -719,6 +764,7 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { if (piSendsArgonLevels) { context.skip(); } + context.skip(claimCannotEngage(out), CLAIM_SKIP); // Recorded, not fixed here: pi's own pattern does not name the id, so pi // builds a budget for it, which the gateway refuses. The plugin sends what // the host's pi builds; bumping pi is the host's fix. @@ -767,7 +813,8 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { expect(out.control).toEqual({ accepted: true }); }); - it("[BASELINE] the Settings graft shows each plane's own entry, and the Models page reads both planes configured under their apiKeyEnv (D18)", () => { + it("[BASELINE] the Settings graft shows each plane's own entry, and the Models page reads both planes configured under their apiKeyEnv (D18)", (context) => { + context.skip(claimCannotEngage(out), CLAIM_SKIP); expect(out.describeEntries).toEqual({ opencode: { apiKeyEnv: "OPENCODE_API_KEY", diff --git a/test/e2e/host/probe-plugin.mjs b/test/e2e/host/probe-plugin.mjs index 29a19217..317b6c8a 100644 --- a/test/e2e/host/probe-plugin.mjs +++ b/test/e2e/host/probe-plugin.mjs @@ -464,6 +464,12 @@ const measure = async (ctx, state) => { ...registered, ...asked, ...saving, + // Which contract the host's `internal/config` waterfall speaks, as one of + // its own dispatches showed it: "config" when a listener is handed the + // config (with the fiber as `this`), "fiber" when it is handed the fiber + // and reads the config from `next()`. Observed, never inferred from a + // version. + configHook: state.configHook ?? null, control, describe, describeEntries, @@ -486,7 +492,17 @@ export const apply = (ctx) => { if (started) return; started = true; let stopped = false; - const state = { controlling: false }; + const state = { configHook: undefined, controlling: false }; + ctx.on?.("internal/config", (first, next) => { + state.configHook ??= + typeof first === "object" && + first !== null && + "uid" in first && + "runtime" in first + ? "fiber" + : "config"; + return next(); + }); ctx.effect?.(() => () => { // The control edit's own restart is not a stop: its answer is still // written. From 76d11e02edc12872bb82b375f5110e10a556f89a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 04:43:09 +0000 Subject: [PATCH 18/20] fix: complete the restack of P-R1 Close the review of the restacked P-R1 head. One test change, the rest documentation and comments; no src behaviour moves. - both-kept-1-F1 / resolutions-1-F1 / rules-docs-1-F1: the "Your row wins" bullet in both READMEs now gives the protocol as the catalog's SDK, then the plane entry's route-level `api` (only for ids the catalog does not classify), then Chat Completions; the plan's P-R1 item says the route `api` covers ids the catalog does not classify (D26). - both-kept-1-F2 / resolutions-1-F2 / rules-docs-1-F2: CONTRIBUTING's release step 3 and the D22'' row name the tables that exist: a pi move re-measures the cell's column of WHOLE_CATALOG and PI_GAPS_BY_CELL, a cluster whose bridge defaults moved updates PI_AI_DEFAULTS (feeding s0PluginFor's catalog-unknown rows), no S0 row follows pi; the three deleted S0_* names are gone. - rules-docs-1-F5: AGENTS.md and CONTRIBUTING say each cell takes its channel's s0PluginFor table, differing only in PI_AI_DEFAULTS, with forHost/forChannel for expectations outside the table. - both-kept-1-F4 / resolutions-1-F5 / rules-docs-1-F3: redirect-planes, CONTRIBUTING's cells bullet and the plan's e2e-host section say the claim-dependent binary-e2e rows run where the route claim engages; on a host whose internal/config hands listeners the fiber a [KNOWN_DEFECT] row asserts pi-ai serves the planes and those rows skip by name, until the 0.2.2-alpha.3 bump flips it. - resolutions-1-F4: the D26 walk now reshapes pi's entry to the served protocol on a /zen root of its own, and walks uncatalogued ids filed by pi under every non-completions protocol. Checked against 7ed93a7's parent: the full old module fails on the legacy base, and the old protocol order alone fails on the uncatalogued walk. - both-kept-1-F3 / resolutions-1-F7: the mock gateway's level-only note, the dispatch assertion message and the binary e2e's pi-root comment no longer describe the deleted wrapper or the retired floating cell. - resolutions-1-F6: hostSendsBudgetTo and claimCannotEngage/CLAIM_SKIP sit above the doc blocks of expectAnswered and hostCannotSave, so each doc is on its own function again. - resolutions-1-F3: the stream hook's price comment no longer points at a dispatch P-R1 deleted. - rules-docs-1-F7: the protocols and model-metadata comments name no model and justify the @ai-sdk/mistral row by the gateway's path; the two other "follows pi" sentences are recorded in the plan's P7 row. - rules-docs-1-F4: the English quota-ring row carries the same apiKeyEnv advice as the Chinese one. - rules-docs-1-F6: engineering-notes' base-URL section is past tense and marked superseded by P-R1 (D13, D26): baseUrlFor over the generated family table is the only base source. - rules-docs-1-F8: the plan's Models-store restatement cites the lines modelsPageRows cites, at each channel record's tag, not rc.2. - rules-docs-1-F9: both READMEs give the gatewayUrls default as both families and list both in the example. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- AGENTS.md | 2 +- CONTRIBUTING.md | 6 ++-- README.md | 7 ++-- README.zh-CN.md | 5 +-- docs/engineering-notes.md | 16 +++++---- docs/improvement-plan.md | 12 ++++--- docs/redirect-planes.md | 2 +- src/stream-hook.ts | 3 +- src/utils/model-metadata.ts | 4 +-- src/utils/protocols.ts | 5 +-- test/e2e/host-binary.e2e.ts | 18 +++++----- test/mock/opencode-gateway.ts | 6 ++-- test/mock/plane-dispatch.mock.ts | 14 ++++---- test/plane-model.test.ts | 58 ++++++++++++++++++++++++-------- 14 files changed, 97 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 31fb1361..2f5e7aea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ 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 hosts the plugin supports, named once: one record per harness channel (`rc` = `dsh@latest`, `alpha` = `dsh@alpha`), each with its `@deepseek-ai` cluster, the cordis family (`cordisFamily`: cordis, the loader, include, group, timer, cosmokit, schemastery, each at the floor of the `~` range the published cluster declares, which is what the harness vendors at the tag and moves only with `dsh`), the bridge's pi RANGE, the newest pi that range resolves (the channel lockfile's) and the range's floor (the harness's own pin, which only the desktop app ships), and `floorOverrides`, the family and the floor pi pair the `e2e-host` floor cell forces, plus which one the install under test is (`DSH_CHANNEL`, else the marker `scripts/use-channel.ts` leaves, else `alpha`). `test/host-versions.test.ts` checks the installed tree against the SELECTED record; `test/harness-channel.test.ts` checks the declarations and holds each committed lockfile (`pnpm-lock.yaml` for alpha, `channels/rc/pnpm-lock.yaml` for rc) to its record, including that `@deepseek-ai/schemastery` resolves to one copy inside the cluster's range and that each family member the cluster peers on is the floor of the declared 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_BY_CELL` the measured per-model rows, tagged per facet, one table per host cell (`newest`, either channel's default install, and `rc-floor`/`alpha-floor`, the pis the desktop app ships; since the route claim every cell is the same table, `S0_TODAY` is the installed cell's, and `forHost`/`forChannel` pick any other per-cell or per-channel expectation), that the binary e2e shares (it reads its cell's `rows` directly; `todayRowsFor`, which only the characterization calls, answers the same rows for either apply order since the route claim). `logLines` captures every logger line at every level, because cordis's own buffer stops at `info` and tier B prints nothing to the console. Tier A has no loader, so the plugin's default host-module source cannot find pi-ai there; the harness swaps in `hostModulesForTests` through the seam in `src/cordis-context.ts`, and the route claim serves the planes in tier A as it does on the real host; `test/host-harness.test.ts` exercises the builder options no contract or characterization case passes, and tier B's two failure paths (an invalid first row, an exported key). Tier B's rows carry PACKAGE names through a stub module loader, never `cordis:` builtins, because the plugin's default host-module source finds pi-ai's entry by `options.name` (`piAiEntry` in `src/host/host-modules.ts`) and a builtin-named row would leave the route claim without pi-ai's module. It also fixes which of pi-ai and the plugin applies first (`order`), because the loader starts rows in parallel and the published binary applies the plugin first; the held row is released when the first one applies or its fiber settles, so an invalid first row fails instead of hanging the boot. +- `test/support/host.ts` — the real host, in-process, in two tiers: **A** (`mountTierA`: a bare cordis `Context`, `LlmRuntime` and `dsh-llm-pi-ai` mounted with `{ providers }`) for the `internal/config` waterfall and pi-ai's credential paths, and **B** (`bootTierB`: dsh-app-boot's `boot()` with a temporary HOME and a test bundle) for the config editor, `settings.describe`, the volatile commit and the characterization of the plugin as it ships. Its fakes (`fakeCredentials`, `fakeLaunchEnvironment`, `recordingFetch`) keep every case offline and its credentials out of the environment (a boot blanks the two keys the plugin reads directly, should the shell export them, and restores them on dispose); `S0_PROFILE` is the contract fixture, `CHARACTERIZED_PROFILE` (S0 plus a declared catalog-unknown id) the characterization's, and `S0_TODAY_BY_CELL` the measured per-model rows, tagged per facet, one table per host cell (`newest`, either channel's default install, and `rc-floor`/`alpha-floor`, the pis the desktop app ships; since the route claim every cell takes its channel's table (`s0PluginFor`), and the two channels' tables differ only in pi-ai's own defaults for the catalog-unknown id (`PI_AI_DEFAULTS`); `S0_TODAY` is the installed cell's, and `forHost`/`forChannel` pick any other per-cell or per-channel expectation outside the table, such as `PI_GAPS`), that the binary e2e shares (it reads its cell's `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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f21e9c5d..3bc09b89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,7 +69,7 @@ What every case does: - **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), `[INTENDED_CHANGE]` (right today; a phase changes it on purpose) or `[HOST_LIMITATION]` (something one channel's host, as one install runs it, lacks or breaks, its pi or its own code, that no phase of this plugin fixes: the install is a host cell for an in-process row, or an `e2e-host` cell, `npm` or `floor`, for the binary e2e; the title names the channel, the host and that install, and it is reported upstream, D26). `RowTag` in `test/support/host.ts` holds the one definition of the four. 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_BY_CELL` in `host.ts` (`S0_TODAY` is the installed cell's), 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. Since P-R1 the plugin places, describes and routes every declared id itself, so every host cell is the same table; a facet carries a per-cell expectation (`forHost`) only where the host's own pi or pi-ai decides it. P-T1b pinned the one facet that depended on the apply order (Go Gemini's call config, on the floors) 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, on every cell, 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), `[INTENDED_CHANGE]` (right today; a phase changes it on purpose) or `[HOST_LIMITATION]` (something one channel's host, as one install runs it, lacks or breaks, its pi or its own code, that no phase of this plugin fixes: the install is a host cell for an in-process row, or an `e2e-host` cell, `npm` or `floor`, for the binary e2e; the title names the channel, the host and that install, and it is reported upstream, D26). `RowTag` in `test/support/host.ts` holds the one definition of the four. 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_BY_CELL` in `host.ts` (`S0_TODAY` is the installed cell's), 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. Since P-R1 the plugin places, describes and routes every declared id itself, so every cell takes its channel's table (`s0PluginFor`), and the two channels' tables differ only in pi-ai's own defaults for the catalog-unknown id (`PI_AI_DEFAULTS`); `forHost`/`forChannel` pick any other per-cell or per-channel expectation outside the table (such as `PI_GAPS`). P-T1b pinned the one facet that depended on the apply order (Go Gemini's call config, on the floors) 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, on every cell, 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.** The rows' 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`), holds every cell's table to the same derivation, and names the model that moved; when it fails on a refresh pull request, update the row in `s0PluginFor` in `host.ts`, the table every cell shares. 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. @@ -79,7 +79,7 @@ What every case does: `.github/workflows/ci.yml`'s `e2e-host` job installs each channel's published `@deepseek-ai/dsh` (its record's `dsh`) into a throwaway prefix with npm, the way a user installs it, 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_BY_CELL` records for its cell, which P-R1 flipped to the plugin owning both planes. -- **Cells, per channel, pi unpatched in both.** `npm` is the published host exactly as npm resolves it, cordis and pi included, what an npm user runs, and asserts the `newest` rows while its pi is the channel record's; a pi published since the record still has to sit in the bridge's range, but the per-model rows (a newer pi's own entry can still move a model's metadata where the catalog states none) are skipped with a warning, and the nightly `host-drift` job names the new pi. 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 every cell, since the plugin owns both planes and none of them depends on which ids pi describes. `floor` is the tree the desktop app ships: npm `overrides` (`floorOverrides` in `host-versions.ts`) force the channel's whole cordis family (cordis, the loader, the include, group and timer plugins, cosmokit, schemastery) at the record's versions, which are the floors of the `~` ranges the published cluster declares (what the harness vendors at the tag), and pi with its telemetry package at the range's floor; the job and the e2e assert that npm applied each one, and the cell asserts every row of the channel's floor cell. In both cells the e2e also holds each cordis-family field to the floor of the range the installed published package declares for it, which no override rewrites, with one rule for cosmokit: its declarer is cordis, so it is checked only where the installed cordis is the record's, and elsewhere only the nightly drift report checks it (today that leaves it out of alpha's `npm` cell, where cordis floats to a newer patch). It is cached by channel, `dsh` and a hash of those overrides, so a cache filled before the record or its overrides moved is never reused; the rest of the tree is what npm resolved when the cache was filled. The installed `dsh` must be the channel record's, so a prefix of the wrong channel fails by name. The save rows skip only where the host itself cannot save (next bullet). +- **Cells, per channel, pi unpatched in both.** `npm` is the published host exactly as npm resolves it, cordis and pi included, what an npm user runs, and asserts the `newest` rows while its pi is the channel record's; a pi published since the record still has to sit in the bridge's range, but the per-model rows (a newer pi's own entry can still move a model's metadata where the catalog states none) are skipped with a warning, and the nightly `host-drift` job names the new pi. 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 every cell where the route claim engages, since the plugin owns both planes there and none of them depends on which ids pi describes; on a host whose `internal/config` waterfall hands listeners the fiber (alpha's `npm` cell today, cordis `4.0.6-alpha.2`), a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes and the rows that need the claim skip by name (next bullet). `floor` is the tree the desktop app ships: npm `overrides` (`floorOverrides` in `host-versions.ts`) force the channel's whole cordis family (cordis, the loader, the include, group and timer plugins, cosmokit, schemastery) at the record's versions, which are the floors of the `~` ranges the published cluster declares (what the harness vendors at the tag), and pi with its telemetry package at the range's floor; the job and the e2e assert that npm applied each one, and the cell asserts every row of the channel's floor cell. In both cells the e2e also holds each cordis-family field to the floor of the range the installed published package declares for it, which no override rewrites, with one rule for cosmokit: its declarer is cordis, so it is checked only where the installed cordis is the record's, and elsewhere only the nightly drift report checks it (today that leaves it out of alpha's `npm` cell, where cordis floats to a newer patch). It is cached by channel, `dsh` and a hash of those overrides, so a cache filled before the record or its overrides moved is never reused; the rest of the tree is what npm resolved when the cache was filled. The installed `dsh` must be the channel record's, so a prefix of the wrong channel fails by name. The save rows skip only where the host itself cannot save (next bullet). - **The host's own failures are recorded, not worked around.** Only the `npm` cell lets cordis float, as the published host's `~` ranges do for a user, and the job reports what it resolved. Where that leaves the host unable to run its own save path, the run records a `[HOST_LIMITATION]` row (titled with the channel and the `dsh`) and skips the save rows. It is detected from what the host answered, never from a version (D26): the plane edit and a control edit no plugin's listener reads (the probe's own entry) both fail with the same `TypeError` thrown by `@deepseek-ai/dsh-app-boot`; where the host saves, the row skips and a `[BASELINE]` row asserts the control was accepted. Today that is alpha's `0.2.2-alpha.2` as npm installs it: cordis `4.0.6-alpha.2` calls `internal/update` in a new shape while `dsh-app-boot` registers its listener in the old one, so every config-editor save throws `next is not a function`. The alpha floor, on the cordis the harness vendors at the tag, saves. The same cordis hands `internal/config` listeners the fiber and reads the config from `next()`, a contract the route claim does not speak yet: the probe records which contract the host dispatched (`configHook`), and where it is the new one a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes and the rows that need the claim skip by name. The phase that bumps alpha to `0.2.2-alpha.3`, which requires that cordis, adopts the contract (feature-detected) and flips the row. - **Triggers.** Dispatch and the nightly run take both cells of both channels. A pull request takes the `npm` cell of both channels, 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 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` (what the job installs), `scripts/host-drift.ts` (the family table the binary e2e asserts against the installed manifests), `.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. @@ -161,7 +161,7 @@ git diff --name-only main... \ 1. Use conventional commits (`feat:`, `fix:`) — release-please opens the version-bump + changelog PR automatically; merging it tags and creates the GitHub Release, which fires OIDC publishing to npm and GitHub Packages. 2. Avoid manual releases: release-please anchors on the latest GitHub **Release**, not the latest tag, so a bare tag is invisible to it (`AGENTS.md`, "Releasing"). If one is unavoidable, bump `version` in `package.json`, commit, tag, push the tag, and create the GitHub Release in the same step (`gh release create vX.Y.Z --generate-notes`). Never touch `CHANGELOG.md`: release-please owns it. -3. On every release, after smoke-testing (registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds), check the **Supported: the latest release of both DeepSeek Harness channels** line and the **Support policy** paragraph under "Compatibility & Verification" in both READMEs. Their "today" versions, pi ranges and floors, and the default install named a paragraph below, are copied from the channel records in `test/support/host-versions.ts`, and `test/harness-channel.test.ts` holds them to the records. A channel bump **inside the prerelease tuples the peer ranges already admit** (a new `dsh` cut whose cluster changes no host hook, say) moves version data, and this is its whole set: for `rc`, the rc record and `channels/rc/pnpm-lock.yaml` (`scripts/use-channel.ts rc --relock`); for `alpha`, the alpha record, `package.json`'s exact `@deepseek-ai/*` pins (the cordis family's among them) and the root lockfile (`pnpm install`); for either, those README sentences in both languages, and `minimumReleaseAgeExclude` lines in `pnpm-workspace.yaml` for the exact versions when the cut is younger than pnpm's one-day release-age window. A pi move is the same data: `pnpm install` and `--relock` keep the pi a lockfile already satisfies, so move it with `pnpm update @earendil-works/pi-ai --no-save` (the root lockfile; `package.json` keeps the union range), then `scripts/use-channel.ts rc --relock`, which carries the root's pi into the rc lockfile, and set each record's `pi`. The cordis family (cordis, the loader, include, group, timer, cosmokit, schemastery) moves only with `dsh`, to the floors of the `~` ranges the new tag's cluster declares (the drift report prints them; they are what the harness vendors at that tag): a newer family patch inside the same ranges is what the `e2e-host` npm cell already runs and logs, never a record bump. A relock keeps the family at the record's floors, because `package.json` pins every member the install resolves and `pinFor` moves each with the channel (the timer plugin, resolved by nothing here, has no pin). The same update is what leaves one pi when a new bridge raises its floor above the locked one. The F10 characterization (`test/mount-protocols.test.ts`) is keyed by pi line, so a pi inside a line leaves it alone; a pi outside every line is a new major, and a reviewed change. **Data only while the host behaves the same.** A bump stays version data while tiers A and B and the binary e2e stay green on the new install. A pull request runs only the default installs, so a bump that moves `piFloor` or any cordis-family field is green only after a `workflow_dispatch` run on the bump branch, which adds both channels' floor cells to the `test` and `e2e-host` jobs. One that turns them red because the host behaves differently is a reviewed change made in the bump's own pull request and named in its body: a pi whose catalog or bases moved (a model added or dropped, a gap id learned) re-measures the `newest` cell's tables (`WHOLE_CATALOG` in `test/host-shapes.test.ts`, `PI_GAPS_BY_CELL` and `S0_NEWEST` in `test/support/host.ts`), and a moved `piFloor` re-measures that floor's tables the same way (`S0_RC_FLOOR` or `S0_ALPHA_FLOOR`, and the floor's columns of `WHOLE_CATALOG` and `PI_GAPS_BY_CELL`), and a host event whose call shape or waterfall order moved adapts the tier listeners in `test/support/host.ts` and the contract tests, re-measuring the facts they pin (F8, F12, F14, F20) rather than accepting both shapes. The next alpha cut is that case: cordis `4.0.6-alpha.2` changed the call shape of `internal/config` and `internal/update`, so the `0.2.2-alpha.3` bump rewrites the tier listeners (a follow-up to the restack). A cut that **opens a new prerelease tuple** (dsh `0.2.3-*`, cordis `4.0.7-*`) is not data: semver admits a prerelease only through a comparator in its own tuple, so it is a reviewed range change that also adds the matching `>=X.Y.Z-0 =X.Y.Z-0 >`; +The plan to make it data had four steps, taken together: + +1. the generator reads `api` from each provider entry of `models.dev` and emits a third export, `OPENCODE_PLANE_API: Readonly>`; 2. the shim is regenerated, so the file carries the value rather than a person; -3. `pi-provider.ts` reads that export in `BASE_FOR_PROTOCOL` instead of the literal map; +3. the provider module reads that export instead of the literal map; 4. tests assert the constant agrees with the data. -Steps 1 and 2 alone change nothing, and step 3 without 1 or 2 moves a constant. They belong in one piece. +Steps 1 and 2 alone changed nothing, and step 3 without 1 or 2 would have moved a constant. Steps 1 and 2 shipped; P-R1 took steps 3 and 4 into the plane model, whose family table reads the generated exports. ## `x-opencode-request` is sent again — minted, because the message id is not reachable from here diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index d15b2dd1..614ccc4b 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -68,7 +68,7 @@ Phases land as **stacked pull requests**. Each one is based on the previous phas | D19 | **Plugin settings.** _Stands after the restack: `main`'s copy says `auto` "follows the catalog", but its code agrees that `auto` is `modern`; P1a's copy wins, pinned by `endpoint-copy.test.ts`._ Delete `modelOverrides`, `maxContextWindow`, `modelRoutes` and `internalRoutes`, and the README's nonexistent `hiddenModels` (removed in P0). Keep `endpoint` (`legacy` or `modern`, with `auto` an alias of `modern`). Keep `providers`, redefined as the route ids whose turns the plugin decorates, with both planes always included. | | D20 | **The plane adapter resolves keys by pi-ai's rule.** A named ref resolves through the credentials service, then the launch environment, then fails with `MISSING_CREDENTIAL` and sends no request. With no ref, pi's ambient key applies, plus the `readOpencodeStore` opt-in. Captured and literal keys never feed the adapter; they stay in the fetch patch's fill for an empty or placeholder wire key. | | D21 | The keyless paid-model filter on listings is dropped (D3, D8). Without a key, dispatch fails loudly. | -| D22'' | **Two channel records, named once** in `test/support/host-versions.ts`: `rc` = `dsh@latest` (`0.2.1-rc.5`, cordis `4.0.5`, loader `1.0.6`, schemastery `3.18.5`, bridge pi `^1.1.0`) and `alpha` = `dsh@alpha` (`0.2.2-alpha.2`, cordis `4.0.6-alpha.1`, loader `1.0.7-alpha.1`, schemastery `3.18.6-alpha.1`, bridge pi `^1.3.0`), each with the newest pi its range resolves (the lockfile's) and the range's floor (the harness's pin), and the whole cordis family (cordis, the loader, include, group, timer, cosmokit, schemastery) at the floors of the `~` ranges the published cluster declares, which are what the harness vendors at the tag and the desktop app ships; the family moves only with `dsh`, and a newer patch inside those ranges is what the `e2e-host` npm cell runs, never a record bump. The committed `package.json` is `alpha`'s, which is what `main` follows; the devDependencies gain `dsh-llm`, `dsh-app-boot`, `dsh-config-editor`, `dsh-settings` and `cordis-plugin-loader` for the real-host tiers, and `cordis-plugin-include`, `cordis-plugin-group` and `cosmokit` so a relock holds the family at the record's floors (each arrives transitively, which a fresh resolve would take at the newest patch its `~` range admits). `test/host-versions.test.ts` checks the installed tree against the SELECTED record (`DSH_CHANNEL`, else the switch's marker, else `alpha`); `test/harness-channel.test.ts` keeps `main`'s both-channel peer-range rule and holds each committed lockfile to its record. A bump inside the prerelease tuples the peer ranges admit moves version data only while tiers A and B and the binary e2e stay green on the new install (for a bump that moves `piFloor` or a family field, on a `workflow_dispatch` run of the bump branch, since a pull request runs only the default installs); CONTRIBUTING's release step 3 is the one list of what it moves (for `rc`, the record and `channels/rc/pnpm-lock.yaml`; for `alpha`, the record, `package.json`'s exact pins and the root lockfile; for either, the README pair's "today" sentences, which `test/harness-channel.test.ts` holds to the records, and `minimumReleaseAgeExclude` lines for a cut under a day old). pi moves the same way, through `pnpm update @earendil-works/pi-ai --no-save` and then `rc --relock`, since neither `pnpm install` nor a relock moves a pi the lockfile already satisfies; F10 is keyed by pi line, so a pi inside its line leaves it alone. A bump that turns the tiers or the binary e2e red because the host behaves differently is a reviewed change in the bump's own pull request, named in its body: a pi whose catalog or bases moved re-measures the `newest` cell's tables (`WHOLE_CATALOG`, `PI_GAPS_BY_CELL`, `S0_NEWEST`), a moved `piFloor` that floor's (`S0_RC_FLOOR` or `S0_ALPHA_FLOOR`, and the floor's columns of `WHOLE_CATALOG` and `PI_GAPS_BY_CELL`), and a host event whose call shape or waterfall order moved adapts the tier listeners and re-measures the contracts they pin (F8, F12, F14, F20) rather than accepting both shapes. The `0.2.2-alpha.3` bump is the first: its cordis (`4.0.6-alpha.2`) changed the call shape of `internal/config` and `internal/update`. A cut that opens a new prerelease tuple (dsh `0.2.3-*`, cordis `4.0.7-*`) is a reviewed range change: it adds the matching peer clause and the README pair's peer-range line. Any change to `package.json`'s dependencies or the workspace's catalog or overrides relocks rc in the same commit, and Dependabot ignores the cluster and pi. A nightly step reports drift against the records (P-T1a: CI's `host-drift` job runs `scripts/report-host-drift.ts`, which reads `dsh` at each record's dist-tag, pi at the newest version the bridge's range admits and that range's floor, and each cordis-family member at the floor of the range the tagged cluster declares for it, and prints a warning per field it compares, never a gate); P-T3 turns the report into a proposed bump pull request. _Replaces D22' (the alpha cluster alone, with `rc` only as a peer range) and D22 (the 0.2.1-rc.2 cluster)._ | +| D22'' | **Two channel records, named once** in `test/support/host-versions.ts`: `rc` = `dsh@latest` (`0.2.1-rc.5`, cordis `4.0.5`, loader `1.0.6`, schemastery `3.18.5`, bridge pi `^1.1.0`) and `alpha` = `dsh@alpha` (`0.2.2-alpha.2`, cordis `4.0.6-alpha.1`, loader `1.0.7-alpha.1`, schemastery `3.18.6-alpha.1`, bridge pi `^1.3.0`), each with the newest pi its range resolves (the lockfile's) and the range's floor (the harness's pin), and the whole cordis family (cordis, the loader, include, group, timer, cosmokit, schemastery) at the floors of the `~` ranges the published cluster declares, which are what the harness vendors at the tag and the desktop app ships; the family moves only with `dsh`, and a newer patch inside those ranges is what the `e2e-host` npm cell runs, never a record bump. The committed `package.json` is `alpha`'s, which is what `main` follows; the devDependencies gain `dsh-llm`, `dsh-app-boot`, `dsh-config-editor`, `dsh-settings` and `cordis-plugin-loader` for the real-host tiers, and `cordis-plugin-include`, `cordis-plugin-group` and `cosmokit` so a relock holds the family at the record's floors (each arrives transitively, which a fresh resolve would take at the newest patch its `~` range admits). `test/host-versions.test.ts` checks the installed tree against the SELECTED record (`DSH_CHANNEL`, else the switch's marker, else `alpha`); `test/harness-channel.test.ts` keeps `main`'s both-channel peer-range rule and holds each committed lockfile to its record. A bump inside the prerelease tuples the peer ranges admit moves version data only while tiers A and B and the binary e2e stay green on the new install (for a bump that moves `piFloor` or a family field, on a `workflow_dispatch` run of the bump branch, since a pull request runs only the default installs); CONTRIBUTING's release step 3 is the one list of what it moves (for `rc`, the record and `channels/rc/pnpm-lock.yaml`; for `alpha`, the record, `package.json`'s exact pins and the root lockfile; for either, the README pair's "today" sentences, which `test/harness-channel.test.ts` holds to the records, and `minimumReleaseAgeExclude` lines for a cut under a day old). pi moves the same way, through `pnpm update @earendil-works/pi-ai --no-save` and then `rc --relock`, since neither `pnpm install` nor a relock moves a pi the lockfile already satisfies; F10 is keyed by pi line, so a pi inside its line leaves it alone. A bump that turns the tiers or the binary e2e red because the host behaves differently is a reviewed change in the bump's own pull request, named in its body: a pi whose catalog or bases moved re-measures the `newest` cell's column of `WHOLE_CATALOG` (`test/host-shapes.test.ts`) and of `PI_GAPS_BY_CELL` (`test/support/host.ts`), a moved `piFloor` the floor's columns of the same two, and no S0 row follows pi (D26); a cluster whose bridge defaults moved updates `PI_AI_DEFAULTS`, which `test/host-shapes.test.ts` pins and which feeds `s0PluginFor`'s catalog-unknown rows, and any other row change goes in `s0PluginFor`, the one table every cell shares; a host event whose call shape or waterfall order moved adapts the tier listeners and re-measures the contracts they pin (F8, F12, F14, F20) rather than accepting both shapes. The `0.2.2-alpha.3` bump is the first: its cordis (`4.0.6-alpha.2`) changed the call shape of `internal/config` and `internal/update`. A cut that opens a new prerelease tuple (dsh `0.2.3-*`, cordis `4.0.7-*`) is a reviewed range change: it adds the matching peer clause and the README pair's peer-range line. Any change to `package.json`'s dependencies or the workspace's catalog or overrides relocks rc in the same commit, and Dependabot ignores the cluster and pi. A nightly step reports drift against the records (P-T1a: CI's `host-drift` job runs `scripts/report-host-drift.ts`, which reads `dsh` at each record's dist-tag, pi at the newest version the bridge's range admits and that range's floor, and each cordis-family member at the floor of the range the tagged cluster declares for it, and prints a warning per field it compares, never a gate); P-T3 turns the report into a proposed bump pull request. _Replaces D22' (the alpha cluster alone, with `rc` only as a peer range) and D22 (the 0.2.1-rc.2 cluster)._ | | D23 | **CI's freshness checks gate a pull request only on its own inputs.** A check's inputs are its script and every local module it loads, plus `src/identity.ts` for the CLI version; the generated tables never are. Vendor drift warns, and the nightly run proposes the refresh. The strict input lists in `test/freshness-scope.test.ts` are the record: `cli:version`'s grew by `scripts/vendor-version.ts` when `main` gave the check a pure half (#144), and the copy scan's generated-table skip moved into that half. | | D24 | **The debug gate is `main`'s rule:** naming `debugFile` writes both halves of the log; `debug` governs console output only. `main` chose it (#133), and its README example (`debug: false` with a `debugFile`) relies on it. P1a adopts it, and turns its "writes nothing while debug is off" test into one that asserts the rule. | | D25 | **The tool fallback's plane is `main`'s rule:** the request URL decides the plane, with a plane-blind lookup as the fallback (#138, `coreToolDecision`). P1b drops its route-mapped variant with no fallback. | @@ -400,8 +400,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.** 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` took pi's own base when pi filed the model under the same protocol on a `/zen` root (withdrawn in the restack, D26: the family table is the only base source); 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. +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` took pi's own base when pi filed the model under the same protocol on a `/zen` root (withdrawn in the restack, D26: the family table is the only base source); 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 the catalog does not classify (D26); 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` (`joinProviderDirectory` and `read`, at the lines `modelsPageRows`' own doc cites, read at each channel record's tag), 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. - **Restacked onto both channels (D14'', D22'', D26).** Merged P-H, so it runs on either channel's newest pi and each floor. One row table serves every host cell, since the plugin places, describes and routes every declared id itself; a row differs by cell only where the host decides (pi-ai's own defaults for the catalog-unknown id, per channel). Three removals of compensation for the host's pi: the Gemini level wrapper (`src/host/google-levels.ts`, its wiring, test, mock case, coverage floor and docs) goes, so the Google thinking control is pi's own; pi's catalog entry leaves the protocol order (catalog SDK, then the route's `api`, then completions) and the base (the family table only), so a pi that reshapes its catalog moves no turn; and `plane-profile.ts` restates none of pi-ai's default window and output tokens, reading back what pi-ai's `Config` filled. What the wrapper's removal exposes is recorded, not fixed: pi `1.1.0` (the `rc` floor, its desktop app's pi) sends the Go plane's level-only model a thinking budget, a `[HOST_LIMITATION]` row in the in-process characterization and the binary e2e's floor cell, with the mock and live cases that need the level skipping by pi's own `usesGoogleThinkingLevel`, the READMEs' "Host limitations" list, and an upstream report. Measured on the published binaries: both cells of `rc` and the `alpha` floor serve the planes through the claim; on `alpha`'s npm cell cordis `4.0.6-alpha.2` hands `internal/config` listeners the fiber and reads the config from `next()`, a contract the claim does not speak, so pi-ai serves the planes there — a `[KNOWN_DEFECT]` row (detected from the contract the probe saw dispatched), which the alpha bump to `0.2.2-alpha.3` (requiring that cordis) must fix before it lands. 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. @@ -482,7 +482,7 @@ How each phase changes now that it sits on `main` 1.8.0 and on both harness chan | P-R3 | **New**, after P-R2's re-measure: L2's error mapping in `prepareCall`, L7's `tool_choice` and schema check, L9's `providerRetryAfterMs`, and L10's Off through `none` if measured. | | P5 | Unchanged, plus L1's warn-once when `globalThis.fetch` is no longer the patched function. | | P6 | L8: verify before evicting a key; L4's outcome (drop `x-opencode-request`, or send it only where it is accepted). | -| P7 | L6's client-generation table and L11's context-window overrides table; the `planeForRoute` copies now include `main`'s `isGoPlaneUrl` and `catalogPlaneForRoute` pair. | +| P7 | L6's client-generation table and L11's context-window overrides table; the `planeForRoute` copies now include `main`'s `isGoPlaneUrl` and `catalogPlaneForRoute` pair; the three sentences that justify `@ai-sdk/mistral` → completions by "the plugin follows pi" (`docs/redirect-planes.md` §2, `docs/protocol-routing-and-merge.md`'s Mistral paragraph, and this plan's protocol row in the P-R1 design) are reworded to the plugin's own routing rule, since pi's catalog no longer decides a protocol (D26). | | P8 | The health snapshot shows the catalog's `lastError` on the card. | | P9 | A loopback triple guard, only if a `webServer` route appears. | | P10a | L1's per-call shaping is the first move of `fetch-patch` into `request/`. | @@ -658,10 +658,12 @@ Two community plugins solve the same problem on the same host: [`FishBottle7/ope - (a) each channel's published binary, with pi as npm resolves it (D14''), on PRs that touch the host seam, nightly and on dispatch; it asserts the `newest` cell's rows; - (b) each channel's floor pi, nightly and on dispatch; it asserts that channel's floor cell's rows. -Since P-R1 every cell is one row table, and an npm cell whose pi moved past its channel record skips only the per-model rows (a newer pi's own entry can still move a model's metadata where the catalog states none); 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 every cell, since none of them depends on which ids pi describes. Its probe JSON is the record. +Since P-R1 every cell is one row table, and an npm cell whose pi moved past its channel record skips only the per-model rows (a newer pi's own entry can still move a model's metadata where the catalog states none); 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 every cell where the route claim engages, since none of them depends on which ids pi describes; on a host whose `internal/config` waterfall hands listeners the fiber (alpha's npm cell today, cordis `4.0.6-alpha.2`), a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes and the rows that need the claim skip by name. Its probe JSON is the record. Only the npm cell lets cordis float, as the published host's `~` ranges do for a user. A host that cannot run its own save path under what npm resolved is a `[HOST_LIMITATION]` row, detected from the host's answer and never from a version: the plane edit and a control edit no plugin's listener reads (the probe's own entry) both fail with the same `TypeError` thrown by `@deepseek-ai/dsh-app-boot`; the save rows then skip (D26), and where the host saves, the row skips and a `[BASELINE]` row asserts the control was accepted. Measured 2026-11-03: alpha `0.2.2-alpha.2` as npm installs it resolves cordis `4.0.6-alpha.2`, whose new `internal/update` call shape breaks `dsh-app-boot`'s own listener, so no config-editor save succeeds on the alpha npm cell and the save rows skip there; the alpha floor, on the cordis the harness vendors at the tag (`4.0.6-alpha.1`), saves; `dsh@alpha` has since moved to `0.2.2-alpha.3`, a channel bump the nightly drift report names (D22''). +The same cordis hands `internal/config` listeners the fiber and reads the config from `next()`, a contract the route claim does not speak yet. The probe records which contract the host dispatched (`configHook`), so this is detected from the dispatched contract, never from a version: where it is the new one, a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes, and the plane-provider, registry, listing, per-model, Gemini-level and Settings-graft rows skip by name. The `0.2.2-alpha.3` bump, which requires that cordis, adopts the contract (feature-detected) and flips the row. + ### 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 the plugin dispatches to with the default endpoint (`auto`, an alias of `modern`: since P-R1, all four protocols on the modern family, on every host cell) 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. diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 09f121bb..b5395d3f 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -191,4 +191,4 @@ What carried over from the bridge, and why: - **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 is pi's own (D26).** Google has two thinking controls and a model accepts only its own: a level model 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 decides which control a request carries by its own rule (`usesGoogleThinkingLevel`), which names Gemini 4 from pi `1.3.0` on; the plugin sends what the host's pi builds and re-derives nothing. pi `1.1.0`, the `rc` channel's floor and its desktop app's pi, does not name it, so a Go Gemini 4 turn there carries a budget: a `[HOST_LIMITATION]` row (`test/host-characterization.test.ts`, and the binary e2e's floor cell), reported upstream, with the mock and live cases that need the level skipping by pi's own predicate. A profile that declares Off for a level model gets pi's own disable, `thinkingBudget: 0`; a model that cannot turn thinking off (`gemini-4-argon`) refuses it, so declaring Off there is a profile error. -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` wherever the host's pi names the model (the `[HOST_LIMITATION]` row above elsewhere). +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, in every cell where the route claim engages, 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` wherever the host's pi names the model (the `[HOST_LIMITATION]` row above elsewhere). On a host whose `internal/config` waterfall hands listeners the fiber (alpha's npm cell today, cordis `4.0.6-alpha.2`), the claim does not engage yet: a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes and the claim rows skip by name; the `0.2.2-alpha.3` bump adopts that contract and flips the row. diff --git a/src/stream-hook.ts b/src/stream-hook.ts index f402da35..552b502d 100644 --- a/src/stream-hook.ts +++ b/src/stream-hook.ts @@ -219,8 +219,7 @@ export const createStreamHook = ( // some shared ids differently — one costs twice as much on Zen as on Go — // and the plane-agnostic lookup searches Go FIRST, so a Zen turn was priced // at the Go rate: half of what it cost. The plane decides, because the - // route is what the user configured; it is the same lookup the dispatch - // above makes. + // route is what the user configured. // // The fallback is for a model the plane's own catalog does not describe, // where any price is better than none — and the turn is still attributed to diff --git a/src/utils/model-metadata.ts b/src/utils/model-metadata.ts index 4f626f9e..ea5b4647 100644 --- a/src/utils/model-metadata.ts +++ b/src/utils/model-metadata.ts @@ -144,8 +144,8 @@ const offeredLevels = ( * (`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` + * `none` does not offer Off yet: pi's own Responses entries disagree on + * `none` (some pin `off: null`, others 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. diff --git a/src/utils/protocols.ts b/src/utils/protocols.ts index 55a0e056..3836ebe6 100644 --- a/src/utils/protocols.ts +++ b/src/utils/protocols.ts @@ -5,8 +5,9 @@ * 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`. + * from a model's name. `@ai-sdk/mistral` maps to completions, the + * OpenAI-compatible path the gateway serves that SDK on + * (`docs/redirect-planes.md` §2). * * Every value is a constant or a function of its arguments: this module is in * `utils/` and the architecture test holds it to that. diff --git a/test/e2e/host-binary.e2e.ts b/test/e2e/host-binary.e2e.ts index 5e86aab1..c3b3facf 100644 --- a/test/e2e/host-binary.e2e.ts +++ b/test/e2e/host-binary.e2e.ts @@ -276,14 +276,6 @@ const hostListenerFailed = (verdict: EditVerdict | undefined): boolean => verdict?.errorName === "TypeError" && verdict.thrownBy === "@deepseek-ai/dsh-app-boot"; -/** - * Whether the host cannot save ANY config edit, whoever makes it: the plane - * edit failed in the host's own code, and so did the control — an edit of the - * probe's own entry, which no plugin's listener reads — with the same error. - * A failure of the plane edit alone is about the plane, and the save rows - * report it. Read from what the edits answered, never from a version, so a - * host that fixes it reads false the day it ships (D26). - */ /** * Whether the host's `internal/config` waterfall hands its listeners the fiber * and reads the config from `next()`: cordis `4.0.6-alpha.2`'s contract, which @@ -299,6 +291,14 @@ const claimCannotEngage = (probe: ProbeOutput): boolean => const CLAIM_SKIP = "KNOWN_DEFECT: the host's internal/config waterfall hands listeners the fiber (cordis 4.0.6-alpha.2), a contract the route claim does not speak yet"; +/** + * Whether the host cannot save ANY config edit, whoever makes it: the plane + * edit failed in the host's own code, and so did the control — an edit of the + * probe's own entry, which no plugin's listener reads — with the same error. + * A failure of the plane edit alone is about the plane, and the save rows + * report it. Read from what the edits answered, never from a version, so a + * host that fixes it reads false the day it ships (D26). + */ const hostCannotSave = (probe: ProbeOutput): boolean => hostListenerFailed(probe.saveBlocked) && hostListenerFailed(probe.control) && @@ -685,7 +685,7 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { // 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. + // npm cell's pi included. expect(out.piLoaded).toEqual({ fromHostPrefix: true, path: "node_modules/@earendil-works/pi-ai", diff --git a/test/mock/opencode-gateway.ts b/test/mock/opencode-gateway.ts index c3339cfc..56ecbfed 100644 --- a/test/mock/opencode-gateway.ts +++ b/test/mock/opencode-gateway.ts @@ -520,9 +520,9 @@ const sessionLow12 = (id: string): number | undefined => { /** * 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. + * INVALID_ARGUMENT. Keyed on the MEASURED ids, never on the catalog rule the + * tests use to pick subjects (`test/support/thinking-levels.ts`), so the mock + * does not mirror pi's own rule (`usesGoogleThinkingLevel`) under test. */ const MEASURED_LEVEL_ONLY: ReadonlySet = new Set(["gemini-4-argon"]); diff --git a/test/mock/plane-dispatch.mock.ts b/test/mock/plane-dispatch.mock.ts index fcb9d3da..9fa7d963 100644 --- a/test/mock/plane-dispatch.mock.ts +++ b/test/mock/plane-dispatch.mock.ts @@ -167,12 +167,6 @@ const finishOf = ( | { 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. - */ /** * Whether the installed pi sends `model` a thinking budget the mock gateway * refuses: the Go plane's level-only model on a pi whose own @@ -183,6 +177,12 @@ const hostSendsBudgetTo = (model: string): boolean => model === "gemini-4-argon" && !usesGoogleThinkingLevel({ id: model } as never); +/** + * 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, @@ -831,7 +831,7 @@ describe("plane dispatch · Google thinking by level, never by budget (D16, D26) ); expect( control, - "the Zen catalog has a Gemini model the wrapper takes and pi serves by level" + "the Zen catalog has a Gemini model the catalog marks level-only and pi serves by level" ).toBeDefined(); const values = control?.reasoning_options?.find((option) => option.type === "effort") diff --git a/test/plane-model.test.ts b/test/plane-model.test.ts index 85de28e5..9068675b 100644 --- a/test/plane-model.test.ts +++ b/test/plane-model.test.ts @@ -142,16 +142,16 @@ describe("plane-model · protocol (D16)", () => { // pi 1.5.0 moved its catalog bases to per-protocol roots and refiled // models; nothing the plugin routes may follow. Each declared model is // built twice, once with pi's own entry and once with that entry - // reshaped (another protocol, another base), and must not move. - const reshaped = (pi: PlanePiModel | undefined, id: string) => ({ - ...pi, - api: - pi?.api === "anthropic-messages" - ? "openai-responses" - : "anthropic-messages", - baseUrl: "https://opencode.ai/somewhere/else/v1", - id, - }); + // reshaped, and must not move. The reshape is the one an earlier rule + // would have taken: pi files the model under the protocol it is served + // on, on a `/zen` root of its own, so a legacy base read from pi's entry + // would move every legacy row here. + const OWN_ZEN_ROOT = "https://opencode.ai/zen/beta/v1"; + const reshaped = ( + pi: PlanePiModel | undefined, + id: string, + api: string | undefined + ) => ({ ...pi, api, baseUrl: OWN_ZEN_ROOT, id }); let compared = 0; for (const plane of ["opencode", "opencode-go"] as const) { const catalog = @@ -161,7 +161,7 @@ describe("plane-model · protocol (D16)", () => { const own = planeModelFor(inputFor(plane, spec.id, { endpoint })); const moved = planeModelFor({ ...inputFor(plane, spec.id, { endpoint }), - pi: reshaped(piOf(plane, spec.id), spec.id), + pi: reshaped(piOf(plane, spec.id), spec.id, own.model?.api), }); expect(moved.model?.api, `${plane}/${spec.id} ${endpoint}`).toBe( own.model?.api @@ -179,9 +179,39 @@ describe("plane-model · protocol (D16)", () => { ); // And the reshaped entry really differs from what was served. const served = modelOf(inputFor("opencode", "deepseek-v4-pro")); - expect(reshaped(piOf("opencode", "deepseek-v4-pro"), "x").baseUrl).not.toBe( - served.baseUrl - ); + expect( + reshaped(piOf("opencode", "deepseek-v4-pro"), "x", served.api).baseUrl + ).not.toBe(served.baseUrl); + // A catalog model's protocol always comes from its SDK, so the walk + // above cannot reach the protocol fallback. An id the catalog does not + // classify can: whatever protocol pi files it under, on either plane and + // family, it is sent as completions, or as the route's `api` when the + // plane entry names one. + let uncatalogued = 0; + for (const plane of ["opencode", "opencode-go"] as const) { + for (const endpoint of ["modern", "legacy"] as const) { + for (const api of PROTOCOLS) { + if (api === "openai-completions") { + continue; + } + const piFiled = { + ...inputFor(plane, "x", { endpoint }), + pi: { api, baseUrl: OWN_ZEN_ROOT, id: "x" }, + }; + const label = `${plane} ${endpoint}: pi files x under ${api}`; + expect(modelOf(piFiled).api, label).toBe("openai-completions"); + expect(modelOf(piFiled).baseUrl, label).not.toBe(OWN_ZEN_ROOT); + const routed = PROTOCOLS.find( + (other) => other !== api && other !== "openai-completions" + ); + expect(modelOf({ ...piFiled, routeApi: routed }).api, label).toBe( + routed + ); + uncatalogued += 1; + } + } + } + expect(uncatalogued).toBe(4 * (PROTOCOLS.length - 1)); }); }); From 8bb7b7fd678471ca2d08c9d7f0efc007f53f17b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 05:22:29 +0000 Subject: [PATCH 19/20] docs: record the withdrawn thinking-level rewrite and finish the P-R1 restack sweep Closes the second review round of the P-R1 restack. - both-kept-2-R2-1: README pair, "Changed in 1.x": one paired bullet says the Google thinking control is now the host pi's own, that the level rewrite for older runtimes released in 1.4.3 (#130) is removed, and that on a pi 1.1.0 host (the rc desktop app) the affected model's thinking turns are refused by the gateway on either plane until the host bumps pi; it links to the Host limitations list. - both-kept-2-R2-2: ci.yml's e2e-host npm-cell comment and the binary e2e's header now say the rows are asserted where the route claim engages, name the [KNOWN_DEFECT] claim row and its CLAIM_SKIP skips, and that the save rows skip where the host cannot save. - both-kept-2-R2-3 / rules-docs-2-F1: protocols.ts no longer states an unmeasured gateway path; it grounds the Mistral SDK row in the generated family table (the vendor's OpenAI root), says the dialect is unmeasured, and points at the plan's P7 row. The P7 row now lists all six places that justify the row by pi's data (the README pair's SDK table rows and protocol-routing-and-merge's table row added). - both-kept-2-R2-4 / rules-docs-2-F3: the characterization header says every S0 facet and named case is [BASELINE] except the one [HOST_LIMITATION] row, and todayRowsFor "answers the cell's rows for either order" (header and CONTRIBUTING). - both-kept-2-R2-5: the README pair's support policy and ci.yml's test-job comment say the in-process suites run each channel's cluster with pi as npm resolves it and the cordis family at the versions the harness vendors (a second exception beside schemastery), and that the e2e-host npm cell is the run that lets cordis float. - resolutions-2-F1: both D15 save rows in the binary e2e also skip with CLAIM_SKIP where the route claim cannot engage; CONTRIBUTING and the plan's skipped-row list say so. Test-only: the change is the skip in the e2e itself, which no offline host can drive red. - resolutions-2-F2: the Host limitations bullet (both languages), redirect-planes, CONTRIBUTING's host cells, the plan's P-R1 record and the mock's predicate doc scope the pi 1.1.0 Google thinking-level limitation to the model on either plane: measured live on Go, expected on Zen under the same pi rule and not yet measured there. The in-process row stays Go-only (S0 declares it there); declaring it on Zen would move S0 rows and is left out. - resolutions-2-F3: patch-method's rewrapOnAdaptersUpdated doc keeps the two caveats and drops the deleted bridge, hidden-route and KNOWN_DEFECT references; host-shapes' F10 doc says the plane adapter routes Go's Google models; reload.test's reconfigured doc drops the deleted listing, keyless filter and reinstall-case sentences; an architecture tripwire comment puts the deleted decoration in the past tense. - rules-docs-2-F2: the plan's defect table strikes the mode-merging row as fixed by P-R1 (one plane adapter answers picker and dispatch in D17's order; toggle models offer Off / On, D1), cited at utils/model-metadata.ts and utils/plane-model.ts, and re-cites the P3 row by function name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- .github/workflows/ci.yml | 25 +++++++++++++++++-------- CONTRIBUTING.md | 6 +++--- README.md | 5 +++-- README.zh-CN.md | 5 +++-- docs/improvement-plan.md | 10 +++++----- docs/redirect-planes.md | 2 +- src/host/patch-method.ts | 15 +++++---------- src/utils/protocols.ts | 10 +++++++--- test/architecture.test.ts | 4 ++-- test/e2e/host-binary.e2e.ts | 24 ++++++++++++++++++++++-- test/host-characterization.test.ts | 15 +++++++++------ test/host-shapes.test.ts | 4 ++-- test/mock/plane-dispatch.mock.ts | 2 +- test/reload.test.ts | 11 +++-------- 14 files changed, 83 insertions(+), 55 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a9f0d2e..09a1bd8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,10 +64,15 @@ jobs: # `channels/rc/pnpm-lock.yaml` for rc). `scripts/use-channel.ts` switches # the install, and `DSH_CHANNEL`/`DSH_PI` tell `test/host-versions.test.ts` # which record to hold the tree to, so a switch that silently did not happen - # fails there by name. One exception to "what an npm user runs": the switch - # also repins the plugin's own `@deepseek-ai/schemastery` to the channel's - # line, so the rc cell runs the host's schemastery, not the plugin's - # published pin (which is the alpha line). + # fails there by name. Two exceptions to "what an npm user runs": the + # lockfiles pin the whole cordis family (cordis, the loader and the plugins + # beside them) at the record's versions, the floors of the `~` ranges the + # cluster declares, what the harness vendors at the tag, where npm lets it + # float to the newest patch (the `e2e-host` job's `npm` cell is the run that + # lets cordis float); and the switch also repins the plugin's own + # `@deepseek-ai/schemastery` to the channel's line, so the rc cell runs the + # host's schemastery, not the plugin's published pin (which is the alpha + # line). # # The floor cells (pi at the range's floor: 1.1.0 on rc, 1.3.0 on alpha, the # pi the desktop app ships, unpatched) run nightly and on dispatch, not on @@ -400,10 +405,14 @@ jobs: # (`dsh@` from `test/support/host-versions.ts`) installed with npm the # way a user installs it, pi unpatched in both cells: # - `npm`: the published host exactly as npm resolves it, cordis and pi - # included. The record's newest pi asserts every row; a pi published since - # the record skips the rows that depend on which ids pi describes, with a - # warning (the nightly `host-drift` job names it). Never cached: - # re-resolving is the point. + # included. The record's newest pi asserts the rows wherever the route + # claim engages; a pi published since the record skips the rows that + # depend on which ids pi describes, with a warning (the nightly + # `host-drift` job names it). On a host whose `internal/config` waterfall + # hands listeners the fiber (alpha's `npm` cell today), a `[KNOWN_DEFECT]` + # row asserts that pi-ai serves both planes and the rows that need the + # claim skip by name; where the host itself cannot save, the save rows + # skip. Never cached: re-resolving is the point. # - `floor`: the tree the desktop app ships, forced with npm `overrides` # (`floorOverrides` in host-versions.ts): the channel's whole cordis family # at the record's versions (the floors of the ranges the cluster declares, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bc09b89..5a5db804 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ The e2e files, one job each: `test/support/host.ts` builds the host from the packages this repository installs: the harness channel `test/support/host-versions.ts` selects (the `@deepseek-ai` cluster at that channel's published tag), with pi unpatched at the newest version the bridge's range admits, or at the range's floor after `scripts/use-channel.ts --floor`. The routing work is judged against it: the contract tests pin the host facts the design rests on that the host can answer in-process (`docs/improvement-plan.md`: F1, F2, F4, F5, F8, F10–F16, F18 and F20, each by number; F6 and F19's auth half (constructing a `PiAiAdapter`) are first exercised by P-R1; F19's export half is pinned in `host-shapes` (`recordKeyFor`); F17 is honoured, not pinned — tier B fixes the apply order and the binary e2e infers it; F9 is a fact about opencode's own client, outside this host), and the characterization rows record what today's plugin does. -**Host cells.** The rows are measured on three cells (`HostCell` in `host.ts`): `newest`, either channel's default install (pi 1.5.0 today, one pi for both channels, so one column; a fixture case fails the day the two records disagree), and `rc-floor` (pi 1.1.0) and `alpha-floor` (pi 1.3.0), the pis the desktop app ships. Where the cells differ, a row carries one expectation per cell (`forHost`, keyed by the cell's role, never by a pi version) or per channel (`forChannel`, for what the `@deepseek-ai` cluster decides, such as pi-ai's own defaults), and every tier file runs on whichever cell is installed. CI runs the unit suite, tiers A and B included, on both channels' `newest` install on every pull request and on both floors nightly and on dispatch; locally, `scripts/use-channel.ts` switches the install and the same run reads the cell from the marker it leaves. The cells differ today in pi's catalog bases (the legacy family on 1.1.0, the root `/inference(/go)/v1` on 1.3.0, per-protocol roots on 1.5.0, whose Zen catalog is net five models smaller than 1.3.0's), in which S0 ids pi can place (`PI_GAPS_BY_CELL`: 1.5.0 places both Go gap ids), in whether pi's Go provider carries Google (F10: not on 1.1.0), and in pi-ai's own defaults (the alpha cluster raised them to 1_000_000 / 128_000). Since P-R1 none of that moves what the plugin routes: it builds every model's protocol and base from its own catalog and endpoint table, so a characterization row differs by cell only where the host itself decides — pi-ai's defaults for an id no catalog describes (the plugin restates none), and the Google thinking control pi builds, which pi 1.1.0 lacks for the Go plane's level-only model (a `[HOST_LIMITATION]` row). +**Host cells.** The rows are measured on three cells (`HostCell` in `host.ts`): `newest`, either channel's default install (pi 1.5.0 today, one pi for both channels, so one column; a fixture case fails the day the two records disagree), and `rc-floor` (pi 1.1.0) and `alpha-floor` (pi 1.3.0), the pis the desktop app ships. Where the cells differ, a row carries one expectation per cell (`forHost`, keyed by the cell's role, never by a pi version) or per channel (`forChannel`, for what the `@deepseek-ai` cluster decides, such as pi-ai's own defaults), and every tier file runs on whichever cell is installed. CI runs the unit suite, tiers A and B included, on both channels' `newest` install on every pull request and on both floors nightly and on dispatch; locally, `scripts/use-channel.ts` switches the install and the same run reads the cell from the marker it leaves. The cells differ today in pi's catalog bases (the legacy family on 1.1.0, the root `/inference(/go)/v1` on 1.3.0, per-protocol roots on 1.5.0, whose Zen catalog is net five models smaller than 1.3.0's), in which S0 ids pi can place (`PI_GAPS_BY_CELL`: 1.5.0 places both Go gap ids), in whether pi's Go provider carries Google (F10: not on 1.1.0), and in pi-ai's own defaults (the alpha cluster raised them to 1_000_000 / 128_000). Since P-R1 none of that moves what the plugin routes: it builds every model's protocol and base from its own catalog and endpoint table, so a characterization row differs by cell only where the host itself decides — pi-ai's defaults for an id no catalog describes (the plugin restates none), and the Google thinking control pi builds, which pi 1.1.0 lacks for the level-only Gemini 4 model on either plane (a `[HOST_LIMITATION]` row; the in-process row covers Go, where the S0 profile declares it). Two tiers, both in-process: @@ -69,7 +69,7 @@ What every case does: - **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), `[INTENDED_CHANGE]` (right today; a phase changes it on purpose) or `[HOST_LIMITATION]` (something one channel's host, as one install runs it, lacks or breaks, its pi or its own code, that no phase of this plugin fixes: the install is a host cell for an in-process row, or an `e2e-host` cell, `npm` or `floor`, for the binary e2e; the title names the channel, the host and that install, and it is reported upstream, D26). `RowTag` in `test/support/host.ts` holds the one definition of the four. 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_BY_CELL` in `host.ts` (`S0_TODAY` is the installed cell's), 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. Since P-R1 the plugin places, describes and routes every declared id itself, so every cell takes its channel's table (`s0PluginFor`), and the two channels' tables differ only in pi-ai's own defaults for the catalog-unknown id (`PI_AI_DEFAULTS`); `forHost`/`forChannel` pick any other per-cell or per-channel expectation outside the table (such as `PI_GAPS`). P-T1b pinned the one facet that depended on the apply order (Go Gemini's call config, on the floors) 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, on every cell, 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), `[INTENDED_CHANGE]` (right today; a phase changes it on purpose) or `[HOST_LIMITATION]` (something one channel's host, as one install runs it, lacks or breaks, its pi or its own code, that no phase of this plugin fixes: the install is a host cell for an in-process row, or an `e2e-host` cell, `npm` or `floor`, for the binary e2e; the title names the channel, the host and that install, and it is reported upstream, D26). `RowTag` in `test/support/host.ts` holds the one definition of the four. 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_BY_CELL` in `host.ts` (`S0_TODAY` is the installed cell's), 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` answers the cell's rows for either order), so the tags in `S0_TODAY` are what govern which entries a phase may flip. Since P-R1 the plugin places, describes and routes every declared id itself, so every cell takes its channel's table (`s0PluginFor`), and the two channels' tables differ only in pi-ai's own defaults for the catalog-unknown id (`PI_AI_DEFAULTS`); `forHost`/`forChannel` pick any other per-cell or per-channel expectation outside the table (such as `PI_GAPS`). P-T1b pinned the one facet that depended on the apply order (Go Gemini's call config, on the floors) 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, on every cell, 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.** The rows' 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`), holds every cell's table to the same derivation, and names the model that moved; when it fails on a refresh pull request, update the row in `s0PluginFor` in `host.ts`, the table every cell shares. 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. @@ -79,7 +79,7 @@ What every case does: `.github/workflows/ci.yml`'s `e2e-host` job installs each channel's published `@deepseek-ai/dsh` (its record's `dsh`) into a throwaway prefix with npm, the way a user installs it, 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_BY_CELL` records for its cell, which P-R1 flipped to the plugin owning both planes. -- **Cells, per channel, pi unpatched in both.** `npm` is the published host exactly as npm resolves it, cordis and pi included, what an npm user runs, and asserts the `newest` rows while its pi is the channel record's; a pi published since the record still has to sit in the bridge's range, but the per-model rows (a newer pi's own entry can still move a model's metadata where the catalog states none) are skipped with a warning, and the nightly `host-drift` job names the new pi. 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 every cell where the route claim engages, since the plugin owns both planes there and none of them depends on which ids pi describes; on a host whose `internal/config` waterfall hands listeners the fiber (alpha's `npm` cell today, cordis `4.0.6-alpha.2`), a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes and the rows that need the claim skip by name (next bullet). `floor` is the tree the desktop app ships: npm `overrides` (`floorOverrides` in `host-versions.ts`) force the channel's whole cordis family (cordis, the loader, the include, group and timer plugins, cosmokit, schemastery) at the record's versions, which are the floors of the `~` ranges the published cluster declares (what the harness vendors at the tag), and pi with its telemetry package at the range's floor; the job and the e2e assert that npm applied each one, and the cell asserts every row of the channel's floor cell. In both cells the e2e also holds each cordis-family field to the floor of the range the installed published package declares for it, which no override rewrites, with one rule for cosmokit: its declarer is cordis, so it is checked only where the installed cordis is the record's, and elsewhere only the nightly drift report checks it (today that leaves it out of alpha's `npm` cell, where cordis floats to a newer patch). It is cached by channel, `dsh` and a hash of those overrides, so a cache filled before the record or its overrides moved is never reused; the rest of the tree is what npm resolved when the cache was filled. The installed `dsh` must be the channel record's, so a prefix of the wrong channel fails by name. The save rows skip only where the host itself cannot save (next bullet). +- **Cells, per channel, pi unpatched in both.** `npm` is the published host exactly as npm resolves it, cordis and pi included, what an npm user runs, and asserts the `newest` rows while its pi is the channel record's; a pi published since the record still has to sit in the bridge's range, but the per-model rows (a newer pi's own entry can still move a model's metadata where the catalog states none) are skipped with a warning, and the nightly `host-drift` job names the new pi. 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 every cell where the route claim engages, since the plugin owns both planes there and none of them depends on which ids pi describes; on a host whose `internal/config` waterfall hands listeners the fiber (alpha's `npm` cell today, cordis `4.0.6-alpha.2`), a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes and the rows that need the claim skip by name (next bullet). `floor` is the tree the desktop app ships: npm `overrides` (`floorOverrides` in `host-versions.ts`) force the channel's whole cordis family (cordis, the loader, the include, group and timer plugins, cosmokit, schemastery) at the record's versions, which are the floors of the `~` ranges the published cluster declares (what the harness vendors at the tag), and pi with its telemetry package at the range's floor; the job and the e2e assert that npm applied each one, and the cell asserts every row of the channel's floor cell. In both cells the e2e also holds each cordis-family field to the floor of the range the installed published package declares for it, which no override rewrites, with one rule for cosmokit: its declarer is cordis, so it is checked only where the installed cordis is the record's, and elsewhere only the nightly drift report checks it (today that leaves it out of alpha's `npm` cell, where cordis floats to a newer patch). It is cached by channel, `dsh` and a hash of those overrides, so a cache filled before the record or its overrides moved is never reused; the rest of the tree is what npm resolved when the cache was filled. The installed `dsh` must be the channel record's, so a prefix of the wrong channel fails by name. The save rows skip where the host itself cannot save (next bullet) or the route claim does not engage. - **The host's own failures are recorded, not worked around.** Only the `npm` cell lets cordis float, as the published host's `~` ranges do for a user, and the job reports what it resolved. Where that leaves the host unable to run its own save path, the run records a `[HOST_LIMITATION]` row (titled with the channel and the `dsh`) and skips the save rows. It is detected from what the host answered, never from a version (D26): the plane edit and a control edit no plugin's listener reads (the probe's own entry) both fail with the same `TypeError` thrown by `@deepseek-ai/dsh-app-boot`; where the host saves, the row skips and a `[BASELINE]` row asserts the control was accepted. Today that is alpha's `0.2.2-alpha.2` as npm installs it: cordis `4.0.6-alpha.2` calls `internal/update` in a new shape while `dsh-app-boot` registers its listener in the old one, so every config-editor save throws `next is not a function`. The alpha floor, on the cordis the harness vendors at the tag, saves. The same cordis hands `internal/config` listeners the fiber and reads the config from `next()`, a contract the route claim does not speak yet: the probe records which contract the host dispatched (`configHook`), and where it is the new one a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes and the rows that need the claim skip by name. The phase that bumps alpha to `0.2.2-alpha.3`, which requires that cordis, adopts the contract (feature-detected) and flips the row. - **Triggers.** Dispatch and the nightly run take both cells of both channels. A pull request takes the `npm` cell of both channels, 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 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` (what the job installs), `scripts/host-drift.ts` (the family table the binary e2e asserts against the installed manifests), `.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. diff --git a/README.md b/README.md index 226de358..49708295 100644 --- a/README.md +++ b/README.md @@ -426,13 +426,14 @@ Disabling or removing the plugin hands both planes back to `llm-pi-ai`, with not - **`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 describes always speaks the protocol its SDK names; the plane entry's route-level `api` applies only to ids the catalog does not classify, 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). +- **The Google thinking control is the host pi's own.** Whether a Gemini request carries a thinking _level_ or a token _budget_ is decided by the host's pi, and the plugin re-derives nothing. The rewrite that sent `gemini-4-argon` a level on hosts whose pi does not name it, released in 1.4.3 (#130), is removed: on a host whose pi is `1.1.0` (the `rc` desktop app), a `gemini-4-argon` turn with thinking, on either plane, carries a budget and the gateway refuses it until the host bumps pi. npm installs of either channel and the `alpha` desktop app run pi `1.3.0` or later and are unaffected. See **Host limitations** under [Compatibility & Verification](#-compatibility--verification). - **`@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 **Supported: the latest release of both DeepSeek Harness channels, `rc` and `alpha`, installed with npm or shipped in the desktop app (Node 24+)** -**Support policy.** The plugin supports the **latest** release of **both** channels, as each one actually ships: `rc` is `dsh@latest` (today `0.2.1-rc.5`, whose bridge declares pi `^1.1.0`) and `alpha` is `dsh@alpha` (today `0.2.2-alpha.2`, pi `^1.3.0`). An npm install runs pi **unpatched**, at the newest `1.x` the bridge's range admits; the desktop app ships the pi the harness's own workspace pins (`1.1.0` on `rc`, `1.3.0` on `alpha`). CI runs the unit and offline mock suites on both channels' npm installs on every pull request, and on each channel's floor pi nightly. The plugin neither patches nor re-implements what the host's pi does: where a host's pi lacks something the gateway needs, that is a host limitation, recorded and reported upstream rather than worked around here. +**Support policy.** The plugin supports the **latest** release of **both** channels, as each one actually ships: `rc` is `dsh@latest` (today `0.2.1-rc.5`, whose bridge declares pi `^1.1.0`) and `alpha` is `dsh@alpha` (today `0.2.2-alpha.2`, pi `^1.3.0`). An npm install runs pi **unpatched**, at the newest `1.x` the bridge's range admits; the desktop app ships the pi the harness's own workspace pins (`1.1.0` on `rc`, `1.3.0` on `alpha`). CI runs the unit and offline mock suites on each channel's cluster with pi as npm resolves it (the cordis family at the versions the harness vendors) on every pull request, and on each channel's floor pi nightly; the published binary as npm installs it, cordis floating, runs in the `e2e-host` job. The plugin neither patches nor re-implements what the host's pi does: where a host's pi lacks something the gateway needs, that is a host limitation, recorded and reported upstream rather than worked around here. The harness publishes two **channels**, `alpha` and `rc`, and they are parallel tracks rather than a version ordering: semver sorts `0.2.1-rc.2` _above_ `0.2.1-alpha.5`. The plugin targets two lines on both channels: **`0.2.1`**, the `rc` channel's latest, and the **`0.2.2`** prereleases the `alpha` channel now carries (the test suite's default install runs `0.2.2-alpha.2`; its `rc` install runs `0.2.1-rc.5`). The `@deepseek-ai/dsh-typert-protocol` peer range says so exactly: @@ -444,7 +445,7 @@ The first clause covers the `0.2.1` line on both channels: its lower bound (`alp **Host limitations.** What a supported host lacks today, recorded by the test suite as `[HOST_LIMITATION]` rows (detected from the host's own answer, never from a version) and reported upstream: -- **Gemini 4 thinking levels on the `rc` desktop app.** Its pi (`1.1.0`) does not name `gemini-4-argon` among the models it sends Google's thinking _level_, so a Go Gemini 4 turn carries a thinking _budget_, which the gateway refuses (400 "Thinking budget is not supported for this model"). pi `1.3.0` and later send the level, so every npm install of either channel, and the `alpha` desktop app, are unaffected. Bumping pi is the host's fix; the plugin sends what the host's pi builds. +- **Gemini 4 thinking levels on the `rc` desktop app.** Its pi (`1.1.0`) does not name `gemini-4-argon` among the models it sends Google's thinking _level_, so a `gemini-4-argon` turn with thinking, on either plane, carries a thinking _budget_, which the gateway refuses (400 "Thinking budget is not supported for this model"; measured live on Go, and expected on Zen under the same pi rule, not yet measured there). pi `1.3.0` and later send the level, so every npm install of either channel, and the `alpha` desktop app, are unaffected. Bumping pi is the host's fix; the plugin sends what the host's pi builds. - **Saving settings on `alpha` `0.2.2-alpha.2` as npm installs it.** npm resolves cordis `4.0.6-alpha.2`, which calls the host's own `internal/update` listener in a new shape, so every config-editor save throws inside `dsh-app-boot` (`next is not a function`), whoever makes it. The `alpha` desktop app, on the cordis the harness vendors, saves. The same cordis also hands configuration hooks a new shape, which this plugin's plane routing does not speak yet (a known defect, recorded by the suite and fixed with the `0.2.2-alpha.3` bump that requires this cordis), so on that install both planes run on pi-ai's own routing. | Surface | Target | diff --git a/README.zh-CN.md b/README.zh-CN.md index 3c484a79..f4ee79d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -420,13 +420,14 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 - **`modelOverrides`、`maxContextWindow` 与 `modelRoutes` 已移除**(以及文档中写过、却从未被读取的 `hiddenModels`)。平面条目中模型自己的行可以写名称、上下文窗口、输出上限、输入模态与推理档位,其余由目录补齐;行里的 `contextWindow` 代替 `maxContextWindow`。仍带有这些已移除键的配置行可以通过校验,这些键会被忽略。 - **不再能按模型固定协议。** `modelRoutes` 可以让某个模型走你指定的协议;这一点没有替代。目录描述过的模型总是使用其 SDK 指定的协议;平面条目的路由级 `api` 只作用于目录未归类的 id,`baseURL` 也只在路由级。价格同样不是模型行的字段:先取目录,再取 pi。 - **停用或重载插件会恢复它包装过的宿主方法。** 停用时模型发现与设置嫁接会被撤下,热重载会替换它们而不是再叠一层。两个平面交还给 `llm-pi-ai`——见[移除或停用插件](#移除或停用插件)。 +- **Google 思考控制由宿主的 pi 自己决定。** Gemini 请求携带思考**档位**还是 token **预算**,由宿主的 pi 决定,插件不再自行推导。1.4.3 发布的改写(#130)——在 pi 未列出 `gemini-4-argon` 的宿主上为它发送档位——已移除:在 pi 为 `1.1.0` 的宿主(`rc` 桌面应用)上,无论哪个平面,开启思考的 `gemini-4-argon` 回合都携带预算,网关会拒绝,直到宿主升级 pi。两个通道的 npm 安装与 `alpha` 桌面应用运行 pi `1.3.0` 或更高版本,不受影响。见[兼容性与验证](#-兼容性与验证)中的**宿主限制**。 - **`@viztor/dsh-opencode` 不再发布。** 其最后一个版本上的 "Renamed to @viztor/dsh-opencode-patch" 弃用提示即为最终信息;请改装 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。 ## 📜 兼容性与验证 **支持范围:DeepSeek Harness `rc` 与 `alpha` 两个通道的最新版本,无论通过 npm 安装还是随桌面应用发布(Node 24+)** -**支持策略。** 本插件支持**两个**通道各自的**最新**版本,以它们实际发布的形态为准:`rc` 即 `dsh@latest`(当前为 `0.2.1-rc.5`,其桥接声明 pi `^1.1.0`),`alpha` 即 `dsh@alpha`(当前为 `0.2.2-alpha.2`,pi `^1.3.0`)。npm 安装运行的是**未打补丁**的 pi,版本为桥接范围所允许的最新 `1.x`;桌面应用随附的是 harness 自身工作区锁定的 pi(`rc` 上为 `1.1.0`,`alpha` 上为 `1.3.0`)。CI 在每个拉取请求上,于两个通道的 npm 安装上运行单元与离线 mock 套件,并每晚在各通道的最低 pi 版本上运行。本插件既不修补也不重新实现宿主 pi 的行为:宿主的 pi 若缺少网关所需的能力,那是宿主的限制,会被记录并报告给上游,而不是在这里绕过。 +**支持策略。** 本插件支持**两个**通道各自的**最新**版本,以它们实际发布的形态为准:`rc` 即 `dsh@latest`(当前为 `0.2.1-rc.5`,其桥接声明 pi `^1.1.0`),`alpha` 即 `dsh@alpha`(当前为 `0.2.2-alpha.2`,pi `^1.3.0`)。npm 安装运行的是**未打补丁**的 pi,版本为桥接范围所允许的最新 `1.x`;桌面应用随附的是 harness 自身工作区锁定的 pi(`rc` 上为 `1.1.0`,`alpha` 上为 `1.3.0`)。CI 在每个拉取请求上,于各通道的集群上运行单元与离线 mock 套件(pi 为 npm 解析出的版本,cordis 系列为 harness 自带的版本),并每晚在各通道的最低 pi 版本上运行;按 npm 安装的已发布二进制(cordis 浮动)在 `e2e-host` 任务中运行。本插件既不修补也不重新实现宿主 pi 的行为:宿主的 pi 若缺少网关所需的能力,那是宿主的限制,会被记录并报告给上游,而不是在这里绕过。 Harness 按 `alpha` 与 `rc` 两个**通道**发布,它们是平行的发布线而非版本先后:semver 中 `0.2.1-rc.2` 排序在 `0.2.1-alpha.5` **之上**。本插件在两个通道上都针对两条线:**`0.2.1`**(`rc` 通道的最新版本),以及 `alpha` 通道目前发布的 **`0.2.2`** 预发布版本(测试套件的默认安装运行的是 `0.2.2-alpha.2`;其 `rc` 安装运行的是 `0.2.1-rc.5`)。`@deepseek-ai/dsh-typert-protocol` 的 peer 范围正是这么写的: @@ -438,7 +439,7 @@ Harness 按 `alpha` 与 `rc` 两个**通道**发布,它们是平行的发布 **宿主限制。** 受支持的宿主目前缺少的能力,由测试套件记录为 `[HOST_LIMITATION]` 行(依据宿主自身的应答检测,从不依据版本号),并报告给上游: -- **`rc` 桌面应用上的 Gemini 4 思考档位。** 其 pi(`1.1.0`)不把 `gemini-4-argon` 列为以 Google 思考**档位**发送的模型,因此 Go 平面的 Gemini 4 回合携带的是思考**预算**,网关会拒绝(400 "Thinking budget is not supported for this model")。pi `1.3.0` 及以上发送的是档位,所以两个通道的所有 npm 安装以及 `alpha` 桌面应用都不受影响。升级 pi 是宿主的修复方式;插件发送的是宿主 pi 构造的请求。 +- **`rc` 桌面应用上的 Gemini 4 思考档位。** 其 pi(`1.1.0`)不把 `gemini-4-argon` 列为以 Google 思考**档位**发送的模型,因此无论哪个平面,开启思考的 `gemini-4-argon` 回合携带的都是思考**预算**,网关会拒绝(400 "Thinking budget is not supported for this model";已在 Go 上实测,Zen 上按同一 pi 规则预期相同,尚未实测)。pi `1.3.0` 及以上发送的是档位,所以两个通道的所有 npm 安装以及 `alpha` 桌面应用都不受影响。升级 pi 是宿主的修复方式;插件发送的是宿主 pi 构造的请求。 - **npm 安装的 `alpha` `0.2.2-alpha.2` 上保存设置。** npm 解析出的 cordis `4.0.6-alpha.2` 以新的调用形态调用宿主自身的 `internal/update` 监听器,因此无论谁发起,每次配置编辑器保存都会在 `dsh-app-boot` 内抛出(`next is not a function`)。`alpha` 桌面应用使用 harness 自带的 cordis,可以保存。同一个 cordis 还改变了配置钩子的调用形态,本插件的平面路由尚不支持这一形态(已知缺陷,由测试套件记录,随要求该 cordis 的 `0.2.2-alpha.3` 升级一并修复),因此在该安装上两个平面都运行在 pi-ai 自己的路由上。 | 层面 | 目标 | diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 614ccc4b..9082fad6 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -110,8 +110,8 @@ Phases land as **stacked pull requests**. Each one is based on the previous phas | Defect | Where | | :-- | :-- | | ~~The deprecated wrapper re-exports a `default` that does not exist, an ESM link error.~~ Fixed on `main` (#135); P2 retires the wrapper (D9). | `scripts/publish-scoped.ts:137`, `release.yml:124` | -| Settings shows pi's snapshot, not the catalog | `models-discovery.ts:142-198,532-539` | -| Mode merging is done only for Go→Gemini. For the other modes the picker shows catalog levels while dispatch validates against a different profile: toggle models show Minimal…Max with no Off, and Go `minimax-m3` + Xhigh/Max is refused on the internal Anthropic route. | `models-discovery.ts:607-616,735-760,1063-1203`; `pi-provider.ts:351-386`; `responses-provider.ts:111-150` | +| Settings shows pi's snapshot, not the catalog | `models-discovery.ts`: `mergeDiscoveredModels` and `decorateModelDiscovery` | +| ~~Mode merging is done only for Go→Gemini. For the other modes the picker shows catalog levels while dispatch validates against a different profile: toggle models show Minimal…Max with no Off, and Go `minimax-m3` + Xhigh/Max is refused on the internal Anthropic route.~~ Fixed by P-R1: one plane adapter answers the picker and dispatch in D17's order, and a toggle model offers Off / On (D1). | `utils/model-metadata.ts`, `utils/plane-model.ts` | | ~~`isZenProvider("opencode-google")` is false~~ Fixed on `main`: only `opencode-go` and `opencode-go-*` are excluded. P1a adds the regression test. | `utils/request-shape.ts:273-282` | | ~~`enrichModelsResponse` turns a 401/403/5xx into a 200 catalog. It must still fill the Go plane's 404.~~ Fixed on `main` (#133): a refusal other than 404 passes through. P1a adds the Zen-plane 404 fill, the non-JSON 200 answer and the retry-after passthrough cases. | `models-catalog.ts:731` | | ~~The warning token doesn't exist, so warnings render as caption text.~~ Fixed on `main` (#133): both use `--dsw-alias-state-warn-label`. | `settings-card.tsx:159`, `settings-store-inspect.tsx:250` | @@ -402,7 +402,7 @@ What stays private is pinned by contract tests against the real host packages: - **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.** 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` took pi's own base when pi filed the model under the same protocol on a `/zen` root (withdrawn in the restack, D26: the family table is the only base source); 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 the catalog does not classify (D26); 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` (`joinProviderDirectory` and `read`, at the lines `modelsPageRows`' own doc cites, read at each channel record's tag), 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. - - **Restacked onto both channels (D14'', D22'', D26).** Merged P-H, so it runs on either channel's newest pi and each floor. One row table serves every host cell, since the plugin places, describes and routes every declared id itself; a row differs by cell only where the host decides (pi-ai's own defaults for the catalog-unknown id, per channel). Three removals of compensation for the host's pi: the Gemini level wrapper (`src/host/google-levels.ts`, its wiring, test, mock case, coverage floor and docs) goes, so the Google thinking control is pi's own; pi's catalog entry leaves the protocol order (catalog SDK, then the route's `api`, then completions) and the base (the family table only), so a pi that reshapes its catalog moves no turn; and `plane-profile.ts` restates none of pi-ai's default window and output tokens, reading back what pi-ai's `Config` filled. What the wrapper's removal exposes is recorded, not fixed: pi `1.1.0` (the `rc` floor, its desktop app's pi) sends the Go plane's level-only model a thinking budget, a `[HOST_LIMITATION]` row in the in-process characterization and the binary e2e's floor cell, with the mock and live cases that need the level skipping by pi's own `usesGoogleThinkingLevel`, the READMEs' "Host limitations" list, and an upstream report. Measured on the published binaries: both cells of `rc` and the `alpha` floor serve the planes through the claim; on `alpha`'s npm cell cordis `4.0.6-alpha.2` hands `internal/config` listeners the fiber and reads the config from `next()`, a contract the claim does not speak, so pi-ai serves the planes there — a `[KNOWN_DEFECT]` row (detected from the contract the probe saw dispatched), which the alpha bump to `0.2.2-alpha.3` (requiring that cordis) must fix before it lands. + - **Restacked onto both channels (D14'', D22'', D26).** Merged P-H, so it runs on either channel's newest pi and each floor. One row table serves every host cell, since the plugin places, describes and routes every declared id itself; a row differs by cell only where the host decides (pi-ai's own defaults for the catalog-unknown id, per channel). Three removals of compensation for the host's pi: the Gemini level wrapper (`src/host/google-levels.ts`, its wiring, test, mock case, coverage floor and docs) goes, so the Google thinking control is pi's own; pi's catalog entry leaves the protocol order (catalog SDK, then the route's `api`, then completions) and the base (the family table only), so a pi that reshapes its catalog moves no turn; and `plane-profile.ts` restates none of pi-ai's default window and output tokens, reading back what pi-ai's `Config` filled. What the wrapper's removal exposes is recorded, not fixed: pi `1.1.0` (the `rc` floor, its desktop app's pi) sends the level-only Gemini 4 model a thinking budget on either plane (measured live on Go), a `[HOST_LIMITATION]` row in the in-process characterization and the binary e2e's floor cell, with the mock and live cases that need the level skipping by pi's own `usesGoogleThinkingLevel`, the READMEs' "Host limitations" list, and an upstream report. Measured on the published binaries: both cells of `rc` and the `alpha` floor serve the planes through the claim; on `alpha`'s npm cell cordis `4.0.6-alpha.2` hands `internal/config` listeners the fiber and reads the config from `next()`, a contract the claim does not speak, so pi-ai serves the planes there — a `[KNOWN_DEFECT]` row (detected from the contract the probe saw dispatched), which the alpha bump to `0.2.2-alpha.3` (requiring that cordis) must fix before it lands. 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. @@ -482,7 +482,7 @@ How each phase changes now that it sits on `main` 1.8.0 and on both harness chan | P-R3 | **New**, after P-R2's re-measure: L2's error mapping in `prepareCall`, L7's `tool_choice` and schema check, L9's `providerRetryAfterMs`, and L10's Off through `none` if measured. | | P5 | Unchanged, plus L1's warn-once when `globalThis.fetch` is no longer the patched function. | | P6 | L8: verify before evicting a key; L4's outcome (drop `x-opencode-request`, or send it only where it is accepted). | -| P7 | L6's client-generation table and L11's context-window overrides table; the `planeForRoute` copies now include `main`'s `isGoPlaneUrl` and `catalogPlaneForRoute` pair; the three sentences that justify `@ai-sdk/mistral` → completions by "the plugin follows pi" (`docs/redirect-planes.md` §2, `docs/protocol-routing-and-merge.md`'s Mistral paragraph, and this plan's protocol row in the P-R1 design) are reworded to the plugin's own routing rule, since pi's catalog no longer decides a protocol (D26). | +| P7 | L6's client-generation table and L11's context-window overrides table; the `planeForRoute` copies now include `main`'s `isGoPlaneUrl` and `catalogPlaneForRoute` pair; the six places that justify `@ai-sdk/mistral` → completions by "the plugin follows pi" (`docs/redirect-planes.md` §2, `docs/protocol-routing-and-merge.md`'s Mistral paragraph and its SDK table row, this plan's protocol row in the P-R1 design, and the SDK table row in `README.md` and `README.zh-CN.md`) are reworded to the plugin's own routing rule, since pi's catalog no longer decides a protocol (D26). | | P8 | The health snapshot shows the catalog's `lastError` on the card. | | P9 | A loopback triple guard, only if a `webServer` route appears. | | P10a | L1's per-call shaping is the first move of `fetch-patch` into `request/`. | @@ -662,7 +662,7 @@ Since P-R1 every cell is one row table, and an npm cell whose pi moved past its Only the npm cell lets cordis float, as the published host's `~` ranges do for a user. A host that cannot run its own save path under what npm resolved is a `[HOST_LIMITATION]` row, detected from the host's answer and never from a version: the plane edit and a control edit no plugin's listener reads (the probe's own entry) both fail with the same `TypeError` thrown by `@deepseek-ai/dsh-app-boot`; the save rows then skip (D26), and where the host saves, the row skips and a `[BASELINE]` row asserts the control was accepted. Measured 2026-11-03: alpha `0.2.2-alpha.2` as npm installs it resolves cordis `4.0.6-alpha.2`, whose new `internal/update` call shape breaks `dsh-app-boot`'s own listener, so no config-editor save succeeds on the alpha npm cell and the save rows skip there; the alpha floor, on the cordis the harness vendors at the tag (`4.0.6-alpha.1`), saves; `dsh@alpha` has since moved to `0.2.2-alpha.3`, a channel bump the nightly drift report names (D22''). -The same cordis hands `internal/config` listeners the fiber and reads the config from `next()`, a contract the route claim does not speak yet. The probe records which contract the host dispatched (`configHook`), so this is detected from the dispatched contract, never from a version: where it is the new one, a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes, and the plane-provider, registry, listing, per-model, Gemini-level and Settings-graft rows skip by name. The `0.2.2-alpha.3` bump, which requires that cordis, adopts the contract (feature-detected) and flips the row. +The same cordis hands `internal/config` listeners the fiber and reads the config from `next()`, a contract the route claim does not speak yet. The probe records which contract the host dispatched (`configHook`), so this is detected from the dispatched contract, never from a version: where it is the new one, a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes, and the plane-provider, registry, listing, per-model, Gemini-level, Settings-graft and save rows skip by name. The `0.2.2-alpha.3` bump, which requires that cordis, adopts the contract (feature-detected) and flips the row. ### The live gateway job (`OPENCODE_API_KEY` / `OPENCODE_GO_API_KEY`) diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index b5395d3f..6f6e212b 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -189,6 +189,6 @@ What carried over from the bridge, and why: - **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 is pi's own (D26).** Google has two thinking controls and a model accepts only its own: a level model 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 decides which control a request carries by its own rule (`usesGoogleThinkingLevel`), which names Gemini 4 from pi `1.3.0` on; the plugin sends what the host's pi builds and re-derives nothing. pi `1.1.0`, the `rc` channel's floor and its desktop app's pi, does not name it, so a Go Gemini 4 turn there carries a budget: a `[HOST_LIMITATION]` row (`test/host-characterization.test.ts`, and the binary e2e's floor cell), reported upstream, with the mock and live cases that need the level skipping by pi's own predicate. A profile that declares Off for a level model gets pi's own disable, `thinkingBudget: 0`; a model that cannot turn thinking off (`gemini-4-argon`) refuses it, so declaring Off there is a profile error. +- **A Gemini model's thinking control is pi's own (D26).** Google has two thinking controls and a model accepts only its own: a level model 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 decides which control a request carries by its own rule (`usesGoogleThinkingLevel`), which names Gemini 4 from pi `1.3.0` on; the plugin sends what the host's pi builds and re-derives nothing. pi `1.1.0`, the `rc` channel's floor and its desktop app's pi, does not name it, so a `gemini-4-argon` turn with thinking there carries a budget on either plane (the rule reads the model id, not the plane; the refusal is measured live on Go, and Zen's, expected under the same rule, is not yet measured): a `[HOST_LIMITATION]` row (`test/host-characterization.test.ts`, and the binary e2e's floor cell), reported upstream, with the mock and live cases that need the level skipping by pi's own predicate. A profile that declares Off for a level model gets pi's own disable, `thinkingBudget: 0`; a model that cannot turn thinking off (`gemini-4-argon`) refuses it, so declaring Off there is a profile error. 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, in every cell where the route claim engages, 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` wherever the host's pi names the model (the `[HOST_LIMITATION]` row above elsewhere). On a host whose `internal/config` waterfall hands listeners the fiber (alpha's npm cell today, cordis `4.0.6-alpha.2`), the claim does not engage yet: a `[KNOWN_DEFECT]` row asserts that pi-ai serves both planes and the claim rows skip by name; the `0.2.2-alpha.3` bump adopts that contract and flips the row. diff --git a/src/host/patch-method.ts b/src/host/patch-method.ts index 2e6cc471..589c4475 100644 --- a/src/host/patch-method.ts +++ b/src/host/patch-method.ts @@ -322,21 +322,16 @@ export const inspectPatch = ( * Install now, and again after every `llm/adapters-updated`, for a site that * has to follow the adapter registry rather than the registry it saw first. * - * Wired to no site yet: on the plane-adapter decoration it would wrap pi-ai's - * shared adapter in the plugin-first order too, and flip the host - * characterization's `[KNOWN_DEFECT]` rows. + * No site uses it today: no site wraps a shared adapter. * * Two things the first caller has to know. The listener reinstalls on EVERY * registry change while it is live, the caller's own included, so a caller that * also registers adapters disposes this BEFORE it releases them: the release * emits `llm/adapters-updated` synchronously, and a live listener would - * reinstall the site once per registration the release withdraws (today each - * Google bridge handle and the mounted pi-ai's adapter handle; its - * configurable-provider registration is answered locally by the mount's facade - * and emits nothing). The fetch-patch cleanup in `lifecycle.ts` releases the - * hidden routes first, so order it there when this is wired. And `install` runs synchronously inside the host's per-listener - * `try`/`catch` in `LlmRuntime.emitAdaptersUpdated`, so a throw from a - * reinstall is logged by the host, not propagated to the plugin. + * reinstall the site once per registration the release withdraws. And + * `install` runs synchronously inside the host's per-listener `try`/`catch` in + * `LlmRuntime.emitAdaptersUpdated`, so a throw from a reinstall is logged by + * the host, not propagated to the plugin. * * @param ctx - the context whose (global) registry events to follow. * @param install - one install; answers its disposer, or `undefined`. diff --git a/src/utils/protocols.ts b/src/utils/protocols.ts index 3836ebe6..c837bc6e 100644 --- a/src/utils/protocols.ts +++ b/src/utils/protocols.ts @@ -5,9 +5,13 @@ * 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, the - * OpenAI-compatible path the gateway serves that SDK on - * (`docs/redirect-planes.md` §2). + * from a model's name. `@ai-sdk/mistral` is classified as completions: the + * plugin's own rule, consistent with the vendor catalog's family table, + * which places that SDK on the OpenAI root + * (`OPENCODE_FAMILY_API.zen["@ai-sdk/mistral"]` in the generated + * `catalog-data.ts`). Which dialect the gateway serves there is unmeasured, + * and rewording the docs' rationale for the row is the plan's P7 item + * (`docs/improvement-plan.md`). * * Every value is a constant or a function of its arguments: this module is in * `utils/` and the architecture test holds it to that. diff --git a/test/architecture.test.ts b/test/architecture.test.ts index 0afdc422..003066b3 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -653,8 +653,8 @@ describe("architecture · host methods are wrapped only through patchMethod", () // A prototype swap shadows a method without leaving an own property. "Reflect.setPrototypeOf(llm, proto);", "Object.setPrototypeOf(adapter, proto);", - // The receiver reached through a property path, as decoratePlaneAdapters - // reaches the adapter. + // The receiver reached through a property path, as the plane-adapter + // decoration P-R1 deleted reached the adapter. "Object.assign(registration?.adapter, { modelOf: wrapped });", 'Object.defineProperty(this.llm, "listModels", { value: wrapped });', ].join("\n"); diff --git a/test/e2e/host-binary.e2e.ts b/test/e2e/host-binary.e2e.ts index c3b3facf..eced62e8 100644 --- a/test/e2e/host-binary.e2e.ts +++ b/test/e2e/host-binary.e2e.ts @@ -57,8 +57,18 @@ * `0.2.2-alpha.2` as npm installs it (cordis `4.0.6-alpha.2`). * - Google thinking levels are pi's to send. A host whose pi's own * exported predicate (`usesGoogleThinkingLevel`, `api/google-shared`) does - * not name the Go plane's level-only Google model sends it a budget, which the gateway - * refuses: today the rc channel's floor, pi 1.1.0, the desktop app's pi. + * not name the level-only Google model (in either plane's catalog) sends it + * a budget, which the gateway refuses: today the rc channel's floor, pi + * 1.1.0, the desktop app's pi. + * + * One known defect is recorded as a `[KNOWN_DEFECT]` row, detected the same + * way: on a host whose `internal/config` waterfall hands its listeners the + * fiber (`claimCannotEngage`, read from the contract the probe saw the host + * dispatch; alpha's `npm` cell today, cordis `4.0.6-alpha.2`), the route + * claim does not engage, so that row asserts that pi-ai serves both planes, + * and every row that needs the claim (the plane-provider, registry, listing, + * per-model, Gemini-level, Settings-graft and save rows) skips with + * `CLAIM_SKIP`. The save rows also skip where the host cannot save at all. * * `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. @@ -839,6 +849,11 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { if (hostCannotSave(out)) { context.skip(); } + // D15 is the route claim's: with pi-ai serving the planes, pi-ai's own + // strict check refuses the stored gap id, so a host that saves but where + // the claim cannot engage skips these as the KNOWN_DEFECT, not as a + // D15 failure. + context.skip(claimCannotEngage(out), CLAIM_SKIP); expect(out.saveBlocked).toEqual({ accepted: true, id: SAVE_CANDIDATE }); expect(out.saveCleared).toBeUndefined(); }); @@ -847,6 +862,11 @@ describe.skipIf(!ENABLED)("e2e · the published host binary (dsh)", () => { if (hostCannotSave(out)) { context.skip(); } + // D15 is the route claim's: with pi-ai serving the planes, pi-ai's own + // strict check refuses the stored gap id, so a host that saves but where + // the claim cannot engage skips these as the KNOWN_DEFECT, not as a + // D15 failure. + context.skip(claimCannotEngage(out), CLAIM_SKIP); expect(out.save).toEqual({ accepted: true, id: SAVE_CANDIDATE }); expect(out.savedInFile).toBe(true); expect(out.afterSave?.listed).toContain(SAVE_CANDIDATE); diff --git a/test/host-characterization.test.ts b/test/host-characterization.test.ts index 13d6ee28..e2668124 100644 --- a/test/host-characterization.test.ts +++ b/test/host-characterization.test.ts @@ -20,10 +20,13 @@ * 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]`. Since the plugin builds every model - * itself, the cells' tables agree wherever the plugin decides; a row differs - * by cell only where the host's own pi or pi-ai decides (`S0_TODAY_BY_CELL`, - * `forHost`). + * every `S0_TODAY` facet and every named case is now `[BASELINE]`, with one + * exception: the `[HOST_LIMITATION]` row for the Gemini 4 thinking level, + * which runs where pi's own `usesGoogleThinkingLevel` does not name the + * level-only model (the rc floor, pi 1.1.0, D26). Since the plugin builds + * every model itself, the cells' tables agree wherever the plugin decides; a + * row differs by cell only where the host's own pi or pi-ai decides + * (`S0_TODAY_BY_CELL`, `forHost`). * * The per-model expectations live in `S0_TODAY_BY_CELL` (`support/host.ts`; * `S0_TODAY` is the installed cell's table), which the binary e2e reads too, @@ -31,8 +34,8 @@ * 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 + * order (`todayRowsFor` answers the cell's rows for either order), 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. The main block boots in the published host's diff --git a/test/host-shapes.test.ts b/test/host-shapes.test.ts index ec029da6..f2c435e5 100644 --- a/test/host-shapes.test.ts +++ b/test/host-shapes.test.ts @@ -591,8 +591,8 @@ describe(`host · pi ${PI}'s public exports (package.json exports ./providers/*, * F10, per cell: pi 1.1.0's Go provider has no Google API; pi 1.3.0 added * it and 1.5.0 keeps it. A fact about pi, not a limitation of the plugin: * the gateway's Go plane serves Google models whichever pi runs, and the - * plugin routes them itself (the Google bridge today, the plane adapter - * from P-R1), so neither cell's row is a `HOST_LIMITATION`. + * plugin's plane adapter routes them itself, so neither cell's row is a + * `HOST_LIMITATION`. */ const GO_HAS_GOOGLE = forHost({ "alpha-floor": true, diff --git a/test/mock/plane-dispatch.mock.ts b/test/mock/plane-dispatch.mock.ts index 9fa7d963..04632f94 100644 --- a/test/mock/plane-dispatch.mock.ts +++ b/test/mock/plane-dispatch.mock.ts @@ -169,7 +169,7 @@ const finishOf = ( /** * Whether the installed pi sends `model` a thinking budget the mock gateway - * refuses: the Go plane's level-only model on a pi whose own + * refuses: the level-only Gemini 4 model, on either plane, on a pi whose own * `usesGoogleThinkingLevel` does not name it (pi 1.1.0, the rc floor). That is * a HOST_LIMITATION (D26), detected from pi's own predicate, never a version. */ diff --git a/test/reload.test.ts b/test/reload.test.ts index bd9da911..eb100b00 100644 --- a/test/reload.test.ts +++ b/test/reload.test.ts @@ -138,14 +138,9 @@ interface Site { * An activation A whose decoration differs from the default's, and a call * that tells them apart: a stacked install would leak the first activation's * answer through the second's wherever the decoration reads per-activation - * state, which every site does. Most sites read it from config; the listing's - * `listModels` reads its `hasCredential` option (the keyless filter), so its - * A is an install with that option rather than a config. The listing's - * `listProviders` capture is pinned separately, by the `isRouteRegistered` - * reinstall case in `models-discovery.test.ts` (a stacked listing would - * capture the previous install's filtered `listProviders`). The main - * sequence also checks, for every key of every site, that the second - * install REPLACED the first (two live entries of one site id). + * state, which every site does. The main sequence also checks, for every + * key of every site, that the second install REPLACED the first (two live + * entries of one site id). */ readonly reconfigured?: { /** A's config, installed through the site's own `install`. */ From 26d1e7479fd497e01778be67ec8123287fb20d9e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 05:56:20 +0000 Subject: [PATCH 20/20] docs: complete the restack review of P-R1 - both-kept-3-R3-1: CONTRIBUTING's release step 3 and D22'' now say a moved host event is adopted by the route claim's own listener and the tier listeners, detected from the dispatch, with every shape a supported channel dispatches accepted while the channels' cordis differ; the contract facts are re-measured per channel, and dsh@alpha is already 0.2.2-alpha.3. - resolutions-3-r3-F1: the README pair and redirect-planes say every turn of the level-only Gemini 4 model carries a budget on pi 1.1.0, thinkingBudget 0 when no effort is chosen, not only a turn with thinking. - resolutions-3-r3-F2, both-kept-3-R3-2: the "Changed in 1.x" bullet no longer states the gateway's refusal on both planes; it points to Host limitations, which scopes it (measured on Go, expected on Zen). - resolutions-3-r3-F3, both-kept-3-R3-3: comments no longer describe the deleted Google bridge, and the mock's level predicate is scoped to either plane. - rules-docs-3-F1: the support policy and Host limitations, in both languages, say the current dsh@alpha (0.2.2-alpha.3) installed with npm runs both planes on pi-ai's routing until the plugin adopts its cordis's configuration-hook contract. - rules-docs-3-F2: "Changed in 1.x" names the catalog's one @ai-sdk/mistral model as now offered, sent as Chat Completions on the vendor's OpenAI root, its dialect unmeasured. - rules-docs-3-F3: the P7 row lists the two test places that justify the Mistral row by pi's data and drops its count; the mock note cites P7. - rules-docs-3-F4: the manifest case is retitled to what it holds. - rules-docs-3-F5: the plan's host-support bullets and lesson L1 record what P1b and P-R1 already did. - rules-docs-3-F6: credentials.md's login-reader paragraphs move under a v2-record heading, without the bridge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- CONTRIBUTING.md | 2 +- README.md | 9 +++++---- README.zh-CN.md | 9 +++++---- docs/credentials.md | 8 ++++++-- docs/improvement-plan.md | 14 +++++++------- docs/redirect-planes.md | 2 +- test/e2e/protocol-routing.e2e.ts | 2 +- test/harness-channel.test.ts | 5 +++-- test/manifest.test.ts | 14 +++++++------- test/mock/plane-dispatch.mock.ts | 9 ++++++--- 10 files changed, 42 insertions(+), 32 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a5db804..321fffa6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -161,7 +161,7 @@ git diff --name-only main... \ 1. Use conventional commits (`feat:`, `fix:`) — release-please opens the version-bump + changelog PR automatically; merging it tags and creates the GitHub Release, which fires OIDC publishing to npm and GitHub Packages. 2. Avoid manual releases: release-please anchors on the latest GitHub **Release**, not the latest tag, so a bare tag is invisible to it (`AGENTS.md`, "Releasing"). If one is unavoidable, bump `version` in `package.json`, commit, tag, push the tag, and create the GitHub Release in the same step (`gh release create vX.Y.Z --generate-notes`). Never touch `CHANGELOG.md`: release-please owns it. -3. On every release, after smoke-testing (registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds), check the **Supported: the latest release of both DeepSeek Harness channels** line and the **Support policy** paragraph under "Compatibility & Verification" in both READMEs. Their "today" versions, pi ranges and floors, and the default install named a paragraph below, are copied from the channel records in `test/support/host-versions.ts`, and `test/harness-channel.test.ts` holds them to the records. A channel bump **inside the prerelease tuples the peer ranges already admit** (a new `dsh` cut whose cluster changes no host hook, say) moves version data, and this is its whole set: for `rc`, the rc record and `channels/rc/pnpm-lock.yaml` (`scripts/use-channel.ts rc --relock`); for `alpha`, the alpha record, `package.json`'s exact `@deepseek-ai/*` pins (the cordis family's among them) and the root lockfile (`pnpm install`); for either, those README sentences in both languages, and `minimumReleaseAgeExclude` lines in `pnpm-workspace.yaml` for the exact versions when the cut is younger than pnpm's one-day release-age window. A pi move is the same data: `pnpm install` and `--relock` keep the pi a lockfile already satisfies, so move it with `pnpm update @earendil-works/pi-ai --no-save` (the root lockfile; `package.json` keeps the union range), then `scripts/use-channel.ts rc --relock`, which carries the root's pi into the rc lockfile, and set each record's `pi`. The cordis family (cordis, the loader, include, group, timer, cosmokit, schemastery) moves only with `dsh`, to the floors of the `~` ranges the new tag's cluster declares (the drift report prints them; they are what the harness vendors at that tag): a newer family patch inside the same ranges is what the `e2e-host` npm cell already runs and logs, never a record bump. A relock keeps the family at the record's floors, because `package.json` pins every member the install resolves and `pinFor` moves each with the channel (the timer plugin, resolved by nothing here, has no pin). The same update is what leaves one pi when a new bridge raises its floor above the locked one. The F10 characterization (`test/mount-protocols.test.ts`) is keyed by pi line, so a pi inside a line leaves it alone; a pi outside every line is a new major, and a reviewed change. **Data only while the host behaves the same.** A bump stays version data while tiers A and B and the binary e2e stay green on the new install. A pull request runs only the default installs, so a bump that moves `piFloor` or any cordis-family field is green only after a `workflow_dispatch` run on the bump branch, which adds both channels' floor cells to the `test` and `e2e-host` jobs. One that turns them red because the host behaves differently is a reviewed change made in the bump's own pull request and named in its body: a pi whose catalog or bases moved (a model added or dropped, a gap id learned) re-measures the `newest` cell's column of `WHOLE_CATALOG` (`test/host-shapes.test.ts`) and of `PI_GAPS_BY_CELL` (`test/support/host.ts`), and a moved `piFloor` re-measures that floor's columns of the same two; no S0 row follows pi, since the plugin places and routes every declared id itself (D26). A cluster whose bridge defaults moved updates `PI_AI_DEFAULTS` (`test/support/host.ts`), which `test/host-shapes.test.ts` pins against the installed pi-ai and which feeds `s0PluginFor`'s catalog-unknown rows; any other row change goes in `s0PluginFor`, the one table every cell takes its channel's copy of. A host event whose call shape or waterfall order moved adapts the tier listeners in `test/support/host.ts` and the contract tests, re-measuring the facts they pin (F8, F12, F14, F20) rather than accepting both shapes. The next alpha cut is that case: cordis `4.0.6-alpha.2` changed the call shape of `internal/config` and `internal/update`, so the `0.2.2-alpha.3` bump rewrites the tier listeners (a follow-up to the restack). A cut that **opens a new prerelease tuple** (dsh `0.2.3-*`, cordis `4.0.7-*`) is not data: semver admits a prerelease only through a comparator in its own tuple, so it is a reviewed range change that also adds the matching `>=X.Y.Z-0 =X.Y.Z-0