From fd3a6bc0d0c2d68ecb0221c69b4fa22087c83c61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 03:38:44 +0000 Subject: [PATCH] fix: report the gateway's refusals honestly The chat UI replaces an AUTH failure with "API key is invalid" and a QUOTA one with "Request quota exhausted.", so a plane turn the gateway refused for its own reasons (free tier, usage cap, region, retired model, unsupported protocol) read as a bad key or an empty quota. A new pure module, src/utils/gateway-refusal.ts, reads the gateway's typed refusal from pi's failure text and finishes the turn as FREE_TIER or MODEL_UNAVAILABLE instead: neither code is replaced by the UI and neither is retried by default, so the user sees the gateway's own words and, except on Google-protocol models, its status. The plane adapter applies it in prepareCall and stream; pi-ai's own routes and stepped-aside planes keep the bridge's codes. The free-tier tool fallback now treats a read or bash tool without properties as missing, replacing it rather than adding a duplicate, and sends tool_choice "none" on a free chat-completions body that carried no tools. The keyed probes gain recorded rows for a raw session id, session-only headers and the harness User-Agent, plus a free baseline; a keyless usage-cap 429 now reads as free-usage-limited and is left out of the Retry-After record. The README gains the remaining bullets of its "With OpenCode Console installed" section. The rate-limit and reasoning-effort lessons land no code: the recorded run answered no keyed 429 and never measured the field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- .github/workflows/ci.yml | 12 +- AGENTS.md | 9 +- CONTRIBUTING.md | 7 +- README.md | 7 +- README.zh-CN.md | 7 +- docs/improvement-plan.md | 56 ++-- docs/redirect-planes.md | 66 +++-- docs/testing.md | 2 +- src/fetch-patch.ts | 15 +- src/host/plane-provider.ts | 55 +++- src/tool-fallback.ts | 89 ++++-- src/utils/gateway-refusal.ts | 148 ++++++++++ test/e2e/keyed-probes.e2e.ts | 43 ++- test/endpoint-record.test.ts | 16 +- test/gateway-refusal.test.ts | 337 +++++++++++++++++++++++ test/keyed-probes.test.ts | 261 +++++++++++++++++- test/mock/opencode-gateway.ts | 6 +- test/mock/plane-refusals.mock.ts | 335 ++++++++++++++++++++++ test/mock/refusal-classification.mock.ts | 14 +- test/mock/request-shapes.mock.ts | 59 ++++ test/plane-provider.test.ts | 160 +++++++++++ test/support/endpoint-record.ts | 12 +- test/support/keyed-probes.ts | 245 ++++++++++++++-- test/support/src.ts | 12 + test/tool-fallback.test.ts | 224 ++++++++++++++- vite.config.ts | 8 + 26 files changed, 2049 insertions(+), 156 deletions(-) create mode 100644 src/utils/gateway-refusal.ts create mode 100644 test/gateway-refusal.test.ts create mode 100644 test/mock/plane-refusals.mock.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a865db6..ea73521a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -603,10 +603,14 @@ jobs: # opencode-live. Every pull request that does not # touch the scope below, and a push to main. # - `probes`: adds the minimal keyed probes (keyed-probes.e2e.ts), one - # request of a few tokens per plane × protocol × URL shape, plus six - # recorded variants of each plane's default completions cell (request - # id, project, reasoning effort; KEYED_VARIANTS) and every 429's - # Retry-After. A pull request + # request of a few tokens per plane × protocol × URL shape, plus the + # recorded variants of each plane's default completions cell + # (KEYED_VARIANTS: the lessons' request-id, project and + # reasoning-effort rows, and the console study's raw-session, + # session-only and harness-User-Agent rows, three of them on a free + # model sent with no key) and every keyed rate-limit 429's + # Retry-After (the keyless free rows and the free tier's usage cap + # left out). A pull request # that touches src/host/**; src/utils/protocols.ts, which decides the # legacy URLs the probes send and the protocol each probe model speaks; # src/utils/plane-model.ts, which decides the family the plugin diff --git a/AGENTS.md b/AGENTS.md index 4498c171..daab59c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: - `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. Tier B's `pluginRow` files the plugin's row under another id and package (the rename notice's case), and its two failure paths (an invalid first row, in each apply order) are `host-planes`' cases. Tier B's rows carry PACKAGE names through a stub module loader, never `cordis:` builtins, because the plugin's default host-module source finds pi-ai's entry by `options.name` (`piAiEntry` in `src/host/host-modules.ts`) and a builtin-named row would leave the route claim without pi-ai's module. It also fixes which of pi-ai and the plugin applies first (`order`), because the loader starts rows in parallel and the published binary applies the plugin first; the held row is released when the first one applies or its fiber settles, so an invalid first row fails instead of hanging the boot. Its `realm` option puts pi-ai's row in a `credentials` realm the plugin's row does not share, beside a row of the test's own `realmCredentialsPlugin` that provides `credentials` there, the shape a vendor's setup writes (`test/host-planes-foreign.test.ts`). - `test/support/config-shape.ts` — how the host contracts read an `internal/config` call and a fiber's configs on either cordis core, without a version: `configCall` decodes the call (OLD when `this` is a fiber, `function (this: Fiber, raw, next)`; NEW when `this` is null and the first argument is one, `(fiber, next)`), `coreShapeOf` reads the core off the fiber's own fields so a contract can check the shape it saw, `rawConfigOf`/`effectiveConfigOf` are the feature-detected accessors that replace every `_config` and resolved-`config` read, and `sameFiber` names one fiber across the handles a loader entry and a waterfall hand out. Both channel records run the NEW core, so `test/config-shape.test.ts` keeps the OLD shape covered with stand-in fibers and each core's dispatch. The tier listeners (`waterfallSpy`, `stripPlanesListener` in `test/support/host.ts`) are appended, not prepended: on the NEW core a prepended listener is outer to the built-in schema listener and its `next()` is already resolved. - `test/support/keyless-matrix.ts` — the keyless 401-vs-404 probe matrix's cell table and classifier over three URL shapes (legacy `/zen(/go)/v1`, the root `/inference(/go)/v1`, modern `/inference(/go)/{openai,anthropic,google}/…`), the keyless free-model probe's cells and classifier (a `429 FreeUsageLimitError` is its own verdict, never a rate limit or a retry), the System One family's cells (recorded only), and the cells each `endpoint` setting dispatches to (`TODAY_DISPATCH`, `LEGACY_DISPATCH`). Pure, though it lives in `test/support/` rather than `src/utils/`: a function of its arguments, with no code path that can set a credential header; `test/e2e/keyless-matrix.e2e.ts` sends the cells. -- `test/support/keyed-probes.ts` — the keyed cells (the keyless ones with the plane's key in the header its protocol reads, and a body of a few tokens), their classifier, the recorded variants of the default completions cell (`keyedVariantCells`: `x-opencode-request`, `x-opencode-project` and `reasoning_effort: "none"`) with every 429's `Retry-After` (`retryAfterOf`), and the `OPENCODE_E2E_KEYED` tier every keyed live suite reads its key through (`armedKey`). Pure: it takes the variable's value and the key as arguments; `test/e2e/keyed-probes.e2e.ts` sends the cells. +- `test/support/keyed-probes.ts` — the keyed cells (the keyless ones with the plane's key in the header its protocol reads, and a body of a few tokens), their classifier, the recorded variants of the default completions cell (`keyedVariantCells`: `x-opencode-request`, `x-opencode-project` and `reasoning_effort: "none"`, and the console study's rows: the host's raw `session-` id `PROBE_RAW_SESSION_ID` for OC9, the session-only header set for OC8, the harness User-Agent `harnessUserAgent()`, read when a cell is built, for OC6, and the free model's rows sent keyless with the free turn's body) with every keyed rate-limit 429's `Retry-After` (`retryAfterOf`; the keyless free rows and the free tier's usage cap are left out, `docs/redirect-planes.md` §13), and the `OPENCODE_E2E_KEYED` tier every keyed live suite reads its key through (`armedKey`). Pure: it takes the variable's value and the key as arguments; `test/e2e/keyed-probes.e2e.ts` sends the cells. - `test/support/endpoint-record.ts` — the dated markdown tables and lines both live probe suites write, which CI publishes and `docs/redirect-planes.md` §13 holds; `test/endpoint-record.test.ts` keeps that section in the formatter's columns and row order. - `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). `live-probe.ts` also holds the rules that decide whether a live case skips, walks on or fails (formerly `test/e2e/verdict.ts`), exercised offline by `test/probe-verdict.test.ts` and `test/live-probe.test.ts`. - `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. @@ -92,12 +92,13 @@ It is the same trick `test/architecture.test.ts` already uses for the client bun | `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 | +| `gateway-refusal.ts` | new (P-R3): the gateway's typed refusals → `FREE_TIER` or `MODEL_UNAVAILABLE` | | `config-run.ts` | new (P-R1): an `internal/config` run and a fiber's configs on either cordis core | `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. +- `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 (an empty-schema `read`/`bash` counts as missing and is replaced, and a toolless chat-completions body also gets `tool_choice: "none"`; never on Responses). - `src/utils/request-shape.ts` — the fetch patch's PURE decisions, extracted so each branch is unit-testable on its own: which plane a URL belongs to, which credential header a protocol reads, the URL and header source of a `fetch` input, the model id and tool names a JSON body carries, and the Anthropic version constant. Everything here is a function of its arguments — no context, no globals, no I/O. The rule it encodes: **a predicate that lives in a closure cannot be exercised alone**, so its branches survive until some integration path happens to take them. `hasSessionHeader` and `isOpenCodeRequest` were exported and tested; their six private siblings were not, and that was the difference. - `src/key-capture.ts` · `src/go-discovery.ts` · `src/usage.ts` · `src/usage-contract.ts` — credential extraction, quota discovery, the usage service, and the shared shape. - `src/catalog-data.ts` · `src/models-catalog.ts` · `src/models-discovery.ts` — the generated static shim, the OpenCode-catalog parser with SWR refresh, and the Settings discovery wrapper (behind `enrichModels`; off, Fetch is pi's list and `NO_DISCOVERY` passes through): on a plane the plugin serves, Settings → Models → Fetch answers pi's list plus the catalog's servable, non-deprecated models pi lacks (D3, P3), and the catalog alone while pi-ai restarts. `catalog-data.ts` is **generated**; never hand-edit it. @@ -110,12 +111,12 @@ It is the same trick `test/architecture.test.ts` already uses for the client bun - `scripts/generator-kit.ts` · `scripts/catalog-shim.ts` — what the two generators and `scripts/verify-catalog.ts` share: their arguments (`generatorArgs`), the source read (`readSource`), and the ONE staleness rule (`stale`: canonical data differs; key order and formatting never count), with the exit and action it leads to (`outcome`: a file current in data and in text is never rewritten; without `--write` a template change is still `current`, and with `--write` current data in text that is not the renderer's is `rewrite`, which only the limits generator, the one whole-file renderer, does). `catalog-shim.ts` is the catalog generator's pure half — the renderer and the splices, moved verbatim, and `shimStale` over every export the shim carries, the plane and family endpoints included. `test/generators.test.ts` holds both, spawns both generators with `--from` (a source that disagrees with the shipped data exits 1; the limits generator's doc made from the shipped table exits 0) and the limits generator's `--write` from a scratch copy of `scripts/` and `src/` (a template change rewritten, a moved figure written), round-trips each renderer through the formatter against the committed file (`scripts/go-limits-render.ts` is the limits generator's renderer), and spawns `verify-catalog` (offline only: `--from`, `--offline`) and makes it fail. - `scripts/session-band.ts` — `verify-session-band.ts`'s pure half: the vendor's id construction, ported, the four band facts with the answer each must have (`bandMismatches`), and the verdict the script prints (`bandVerdict`): "CONFIRMED" only when there is no mismatch, otherwise one `MISMATCH:` line each and exit 1. The unit run makes the verdict fail on a planted derivation and spawns the script in its passing state. - `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` — the plugin owns the plane routes (D13). The route claim appends ONE `internal/config` listener that reads either cordis core's call shape (`src/utils/config-run.ts`: `function (this: Fiber, raw, next)` on the OLD core, `(fiber, next)` with `this` null on the NEW one), calls `next()` exactly once, re-appends itself whenever pi-ai reaches `internal/status` ACTIVE so it stays inner to pi-ai's own checking listener (the new registration first, the old one released only once the host accepted it, so a refusal while the plugin's fiber is going away throws nothing into pi-ai's emit), and hands `llm-pi-ai`'s fiber a copy of what `next()` answered without the `opencode`/`opencode-go` entries it TAKES — an entry another plugin owns (D27: pi-ai's row resolving `credentials` in a realm the plugin's context does not, compared by the key each context reads off cordis's realm map so a group's `isolate` counts, or an entry carrying the `x-opencode-plugin` header, `src/utils/plane-ownership.ts`) stays pi-ai's, is logged once, and is never counted by a restart, a handback or the reconcile; a commit run releases at once a plane the plugin registered that its candidate no longer lets it take, so pi-ai's registration of it meets no duplicate; and the claim's read-only `steppedAside()` accessor makes the stream hook run such a turn undecorated under a `foreign` turn state (`turn-store.ts`), which the fetch patch passes through before its URL claim, as it does any marked request, and keeps the 1.x discovery wrapper off its Fetch; a commit run (the fiber's raw config, the loader's stored row config, or any run while the fiber is not ACTIVE) never throws, and every fiber config is read through the feature-detected accessors, 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). Its stand-in (`test/support/route-claim-stand-in.ts`, driving `test/route-claim*.test.ts`; the new core's call-shape cases are in `test/route-claim-listener.test.ts`) builds fibers of both cores — `piFiber` (the OLD core's `_config` and resolved `config`, driven through `oldRun`) and `newPiFiber` (the NEW core's raw `config`, `rawConfig` and `effectiveConfig`, no `_config`, driven through the stand-in's `waterfall`, whose per-listener `next` throws on a second call, and its `internal/status` emit) — and `test/host-planes.test.ts` runs the claim on the installed host, whichever core it is. `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/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 appends ONE `internal/config` listener that reads either cordis core's call shape (`src/utils/config-run.ts`: `function (this: Fiber, raw, next)` on the OLD core, `(fiber, next)` with `this` null on the NEW one), calls `next()` exactly once, re-appends itself whenever pi-ai reaches `internal/status` ACTIVE so it stays inner to pi-ai's own checking listener (the new registration first, the old one released only once the host accepted it, so a refusal while the plugin's fiber is going away throws nothing into pi-ai's emit), and hands `llm-pi-ai`'s fiber a copy of what `next()` answered without the `opencode`/`opencode-go` entries it TAKES — an entry another plugin owns (D27: pi-ai's row resolving `credentials` in a realm the plugin's context does not, compared by the key each context reads off cordis's realm map so a group's `isolate` counts, or an entry carrying the `x-opencode-plugin` header, `src/utils/plane-ownership.ts`) stays pi-ai's, is logged once, and is never counted by a restart, a handback or the reconcile; a commit run releases at once a plane the plugin registered that its candidate no longer lets it take, so pi-ai's registration of it meets no duplicate; and the claim's read-only `steppedAside()` accessor makes the stream hook run such a turn undecorated under a `foreign` turn state (`turn-store.ts`), which the fetch patch passes through before its URL claim, as it does any marked request, and keeps the 1.x discovery wrapper off its Fetch; a commit run (the fiber's raw config, the loader's stored row config, or any run while the fiber is not ACTIVE) never throws, and every fiber config is read through the feature-detected accessors, 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); its `prepareCall` stream and `stream` finish the gateway's typed refusals as `FREE_TIER` or `MODEL_UNAVAILABLE` with the gateway's words, and its status except on Google-protocol models, whose failure text carries none (`src/utils/gateway-refusal.ts`), because the conversation replaces an AUTH failure's message with "API key is invalid". Its stand-in (`test/support/route-claim-stand-in.ts`, driving `test/route-claim*.test.ts`; the new core's call-shape cases are in `test/route-claim-listener.test.ts`) builds fibers of both cores — `piFiber` (the OLD core's `_config` and resolved `config`, driven through `oldRun`) and `newPiFiber` (the NEW core's raw `config`, `rawConfig` and `effectiveConfig`, no `_config`, driven through the stand-in's `waterfall`, whose per-listener `next` throws on a second call, and its `internal/status` emit) — and `test/host-planes.test.ts` runs the claim on the installed host, whichever core it is. `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. It never rewrites a plane entry another plugin owns (D27: the `x-opencode-plugin` marker, or a row whose credentials resolve in a realm the service's context does not), nor a reference to a route only such entries declare. - `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/config-run.ts` — an `internal/config` run and a fiber's three configs, read the same way on either cordis core, held at 100% coverage: `configRunOf` decodes the call (the fiber is `this` on the OLD shape, the first argument on the NEW one, `next` the last argument on both), `isFiber` is the structural fiber predicate (`fiber.ctx.fiber === fiber`), `rawConfigOf`/`effectiveConfigOf` are the feature-detected accessors (`rawConfig` else `_config`, `effectiveConfig` else `config`), `entryConfigOf` reads the loader's stored row config (`fiber.entry.options.config`), and `isCommitRun` tells a commit from an editor candidate by identity. `test/support/config-shape.ts` re-exports the predicate and the accessors, so a contract and the claim never read a fiber two ways. Told apart by the call and the fiber's own fields, never by a version. -- `src/utils/protocols.ts` · `src/utils/model-metadata.ts` · `src/utils/plane-model.ts` · `src/utils/plane-profile.ts` · `src/utils/plane-ownership.ts` — the plane's pure decisions, each held at 100% coverage: which declared plane entries the claim takes and which another plugin owns (the `credentials` realm each context resolves, read off cordis's realm map with the rows' labels as the fallback, and the `x-opencode-plugin` marker, D27; `go-discovery.ts` asks the same two questions so the meter never reads another plugin's entry), 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; the family `auto` resolves to, `AUTO_ENDPOINT_FAMILY`, P-R2), 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), and so are the image-count bounds (`maxRequestImages`, and `imageOffloadCountQuantum` derived from it as pi-ai's own profile resolution does) wherever the installed pi-ai's `Config` fills them and absent where it does not. +- `src/utils/protocols.ts` · `src/utils/model-metadata.ts` · `src/utils/plane-model.ts` · `src/utils/plane-profile.ts` · `src/utils/plane-ownership.ts` · `src/utils/gateway-refusal.ts` — the plane's pure decisions, each held at 100% coverage: the code a gateway refusal finishes with (its type token in pi's failure text, the status at the text's head; Google's text carries none), which declared plane entries the claim takes and which another plugin owns (the `credentials` realm each context resolves, read off cordis's realm map with the rows' labels as the fallback, and the `x-opencode-plugin` marker, D27; `go-discovery.ts` asks the same two questions so the meter never reads another plugin's entry), 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; the family `auto` resolves to, `AUTO_ENDPOINT_FAMILY`, P-R2), 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), and so are the image-count bounds (`maxRequestImages`, and `imageOffloadCountQuantum` derived from it as pi-ai's own profile resolution does) wherever the installed pi-ai's `Config` fills them and absent where it does not. - `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 d6c16382..15167f3b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ The e2e files, one job each: - `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. - `keyless-matrix.e2e.ts` — the keyless 401-vs-404 matrix: two planes × four protocols × three URL shapes — legacy (`/zen(/go)/v1`), the root (`/inference(/go)/v1`, the provider-level base the catalog declared until 2026-10-16) and modern (`/inference(/go)/{openai,anthropic,google}/…`) — one PAID model per cell from the live catalog, never a free one, which the gateway serves keyless with a real completion. It **sends no key and reads none**, so the `e2e` job runs it on every pull request, and it retries a cell once on a 429, a 5xx or a transport error before reading it. A 404 means the path is gone, a 401/403 that it exists, a 410, a 400 "Model is unavailable" or a refusal of type `ModelDeprecated` at any status that the model is. It asserts that all twenty-four cells have a model and were sent, that every legacy and modern answer is readable (a root answer is recorded whatever it is: nothing dispatches there), and that no cell the plugin dispatches to today with the default endpoint (`auto`, an alias of `modern`; `TODAY_DISPATCH`: all four protocols on the modern family, per plane) is gone. A redirect is never followed (`redirect: "manual"`): it reads as `redirected`, unreadable. Beside the matrix, a keyless free-model probe sends a free completions model to `/inference/openai/v1/chat/completions` and asserts it is served or free-tier-gated; a retired free model (`model-gone`, a `ModelDeprecated` refusal included) or a busy upstream (a 503, a 529, or a 5xx naming an overload) moves on to the next candidate (`runFreeProbe`, which records the deciding candidate's own model and status), and a rate limit (a 429, or a 429 and then no answer), a busy upstream no candidate got past, or the free tier's usage cap (`429 FreeUsageLimitError`, its own verdict `free-usage-limited`, never retried and never read as served) skips it. The System One family's three shapes on the Zen plane (`/zen/v1/systemone`, `/inference/v1/systemone`, `/inference/systemone/v1/systemone`) are probed keyless with no model and recorded, never asserted: the plugin serves no classifier. `endpoint: legacy` is recorded, not guarded: the cells it dispatches to (`LEGACY_DISPATCH`: completions, Responses and Messages on both planes, and Zen's Google; Go's Google stays on `/inference/go/google/v1beta`, `GO_GOOGLE_BASE_URL`) must answer readably, like every legacy cell, but their path-gone is recorded and asserted nowhere live (Go's Google, whose cell under `endpoint: legacy` is the modern one, is guarded as a default cell), and `test/endpoint-family.test.ts` pins their mapping offline. Both probe suites rewrite their record after every cell, so a job killed at its time limit keeps what it measured. The cell table, the sets and the classifiers are a pure module (`test/support/keyless-matrix.ts`), unit-tested offline in `test/keyless-matrix.test.ts`, which also derives `TODAY_DISPATCH` from the URLs each host cell's rows measured (every cell reaches the same cells: the plugin builds every base from its own endpoint table). -- `keyed-probes.e2e.ts` — the keyed half: the same twenty-four cells, each sent with the plane's own key in the header its protocol reads, the plugin's identity headers (`PROBE_IDENTITY`: the turn's user agent, `x-opencode-client`, `x-opencode-project`, an in-band session id under both session headers — the Go plane refuses a keyed request without one; the per-request metric id `x-opencode-request` is left out on purpose) and a body of a few output tokens, on the `probes` and `full` tiers. Per plane it decides from the `TODAY_DISPATCH` cells (`planeOutcome`), each read beside the legacy and root cells of the same protocol, which carry the same model and key: it fails when one answers path-gone, wrong-protocol or auth-refused, a retired model another shape accepted, or nothing readable — `other`, a redirect, a refused session, `transport-error` or `no-model`. Only a wrong-protocol, path-gone, auth-refused or model-gone that the cell `endpoint: legacy` dispatches to — a different one, so never Go's Google — accepted is flip evidence; a refusal only the root or Go Google's `/zen/go` cell accepted is red but not evidence (no flip reaches it), a key refused on every shape that read it names the CI secret instead, and a protocol or a path the flip's cell measured nothing about asks for a re-run. The account's answer counts as acceptance beside a refused key or a gone path, never beside a refused protocol or model. It skips when none of them was served (the account's answer, a rate limit, a busy upstream, a retired model, a parsed but refused body), since that measured nothing. Every other cell is recorded. Like the keyless matrix, it skips while a mirror base URL is set. A second case per plane records the keyed variants of the default completions cell — a minted `x-opencode-request`, `x-opencode-project` as a folder name and as a 40-hex digest beside the identity's `global`, and a paid completions model that reasons with no `reasoning_effort` and with `"none"` — and every 429's `Retry-After`; they assert nothing (the plan's lessons L4, L5, L9 and L10, for P6 and P-R3). Headers and keys are never printed, and a body only through a redacting excerpt. The cells, tier, classifier and plane outcome are `test/support/keyed-probes.ts`, unit-tested in `test/keyed-probes.test.ts`, which also runs every keyed cell through the offline gateway mock's judge. +- `keyed-probes.e2e.ts` — the keyed half: the same twenty-four cells, each sent with the plane's own key in the header its protocol reads, the plugin's identity headers (`PROBE_IDENTITY`: the turn's user agent, `x-opencode-client`, `x-opencode-project`, an in-band session id under both session headers — the Go plane refuses a keyed request without one; the per-request metric id `x-opencode-request` is left out on purpose) and a body of a few output tokens, on the `probes` and `full` tiers. Per plane it decides from the `TODAY_DISPATCH` cells (`planeOutcome`), each read beside the legacy and root cells of the same protocol, which carry the same model and key: it fails when one answers path-gone, wrong-protocol or auth-refused, a retired model another shape accepted, or nothing readable — `other`, a redirect, a refused session, `transport-error` or `no-model`. Only a wrong-protocol, path-gone, auth-refused or model-gone that the cell `endpoint: legacy` dispatches to — a different one, so never Go's Google — accepted is flip evidence; a refusal only the root or Go Google's `/zen/go` cell accepted is red but not evidence (no flip reaches it), a key refused on every shape that read it names the CI secret instead, and a protocol or a path the flip's cell measured nothing about asks for a re-run. The account's answer counts as acceptance beside a refused key or a gone path, never beside a refused protocol or model. It skips when none of them was served (the account's answer, a rate limit, a busy upstream, a retired model, a parsed but refused body), since that measured nothing. Every other cell is recorded. Like the keyless matrix, it skips while a mirror base URL is set. A second case per plane records, and asserts nothing about: the lessons' variants of the default completions cell (a minted `x-opencode-request`; `x-opencode-project` as a folder name and as a 40-hex digest beside the identity's `global`; a paid completions model that reasons, with no `reasoning_effort` and with `"none"`), for L4, L5 and L10; the console study's rows (the host's raw `session-` session id, OC9; the session-only header set, OC8; the harness's User-Agent, OC6; and a free completions model sent keyless inside the keyed tier with the free turn's body, as the identity, with a raw session id and with the harness's User-Agent), which feed an owner's OC6 decision, the session derivation's OC9 and P6's OC8; and every keyed rate-limit 429's `Retry-After` (`retryAfterOf`; the keyless free rows and the free tier's usage cap are left out, `docs/redirect-planes.md` §13), for L9. Headers and keys are never printed, and a body only through a redacting excerpt. The cells, tier, classifier and plane outcome are `test/support/keyed-probes.ts`, unit-tested in `test/keyed-probes.test.ts`, which also runs every keyed cell through the offline gateway mock's judge. - `protocol-routing.e2e.ts` — asks the gateway which endpoint serves each model's wire format, at the URL the plane adapter sends it to (the modern family by default, Go's Google included): keyless for the free classes, keyed (and skipped without a secret, or below the `full` tier) for paid ones. The keyed record of every URL shape per protocol, the legacy family included, is `keyed-probes.e2e.ts`. 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` — a keyed live block, on the `full` tier, that sends one toggle model per plane and protocol through the plane adapter on a tier-A host with the picker's "On" effort, walking up to three toggle candidates per protocol the same way, and skips without the plane's key, below the `full` tier, or, naming it, when a protocol had no entitled, still-served candidate. Its offline half — every thinking level the picker offers is one pi's own rule accepts, swept over the whole shipped catalog — needs no network and no port, so it is `test/dispatch-levels.test.ts` in the unit run. - `host-binary.e2e.ts` — the published host binary; see [The real-host harness](#the-real-host-harness). It skips unless `DSH_HOST_E2E=1`. @@ -64,7 +64,8 @@ The e2e files, one job each: - `plane-dispatch.mock.ts` — every characterized model on each plane, through the real `LlmRuntime` and the plugin's `PlaneAdapter` on a tier-A host. - `protocol-compat.mock.ts` — plane × protocol × endpoint family, driven through pi's own protocol implementations. - `patched-fetch-headers.mock.ts` — a local `node:http` listener, so the _outgoing_ header set crosses a real socket. The live gateway cannot echo a request back; everywhere else in the suite the injected headers are asserted against a captured `fetch`. -- `refusal-classification.mock.ts` — the refusal classifier's verdicts on the gateway's bytes over a real `node:http` socket. +- `refusal-classification.mock.ts` — the refusal classifier's verdicts on the gateway's bytes over a real `node:http` socket, on pi-ai's own route. +- `plane-refusals.mock.ts` — the gateway's five typed refusals on a plane turn, through the real `LlmRuntime` and the `PlaneAdapter` on a tier-A host: each finishes as `FREE_TIER` or `MODEL_UNAVAILABLE` with the gateway's words, and its status except on Google-protocol models, whose failure text carries none; a 401 and a plain 429 keep the host's reading. Its request count guards against a duplicate dispatch only: retrying belongs to `dsh-llm-retry`, which tier A does not mount, so whether a refusal is resent is judged by its code being outside the plane's `retryableCodes`. - `host-drift-cli.mock.ts` — the nightly host-drift report's CLI against a loopback registry, as CI runs it; the rules it prints are `test/host-drift.test.ts`'s, on the registry views in `test/support/host-drift-fixture.ts`. The bump proposal's CLI (`scripts/propose-host-bump.ts`) runs the same way: a data plan, a cut inside the release-age window, a refusing registry, the `--out` files CI's push step reads (with no tracked file touched without `--write`), and, on a scratch copy through `--root`, a `--write` whose pnpm step fails putting every file back. Its rules are `test/host-bump.test.ts`'s. - `cli-version.mock.ts` — `scripts/check-vendor-version.ts` against a loopback registry through `--registry`: the dist-tags request, a pinned `latest`, a bump, a generation change (`latest` left the pinned major) and a refusing registry. @@ -157,7 +158,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; and the three modules that install the plugin's effects on the host, `lifecycle.ts`, `stream-hook.ts` and `fetch-patch.ts`, just below what they measure too, with the branches no composition reaches named beside the floor. `test/architecture.test.ts` fails the build on a floor whose file no longer exists, which would otherwise be a floor on nothing. +`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`) and `utils/gateway-refusal.ts` at 100%, because each branch is a reload case, a protocol, a precedence, one of pi-ai's checks, a refusal type or a text shape; `host/route-claim.ts`, `host/plane-provider.ts`, `host/describe-graft.ts` and `host/host-modules.ts` just below what they measure; and the three modules that install the plugin's effects on the host, `lifecycle.ts`, `stream-hook.ts` and `fetch-patch.ts`, just below what they measure too, with the branches no composition reaches named beside the floor. `test/architecture.test.ts` fails the build on a floor whose file no longer exists, which would otherwise be a floor on nothing. ## The freshness checks in CI diff --git a/README.md b/README.md index 98f72e89..34573d12 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ Nine controls in four sections — the decisions a user actually makes. Everythi | **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 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 by itself — the picker shows the models you declared, or the plane's catalog when its entry declares none, and this switch changes neither. To declare more, use **Settings → Models → Fetch** on an OpenCode plane: it offers pi's list plus the catalog's models pi lacks, and adopting a row and saving declares it. 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 | +| **Models & Free Tier** | Inject Core Tools | `on` | Adds the `read` + `bash` schemas free-tier `/responses` and Chat Completions bodies require, replacing a `read` or `bash` declared with an empty schema, and puts `tool_choice: "none"` on a free Chat Completions body that brought no tools of its own | | **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) | | **Account & Endpoint** | Credential Source | `auto` | Which key wins when several are known: **Automatic** · **Live request first** · **Declared key first** | @@ -411,12 +411,14 @@ OpenCode's own Console plugin (`npx @opencode/console setup deepseek-harness`) c - **Settings → Models → Fetch on a Console route is `llm-pi-ai`'s own list.** No catalog model is added to it, and while `llm-pi-ai` restarts the fetch fails as `llm-pi-ai`'s own does rather than answering the catalog; a plane entry of your own beside it keeps the catalog's models. - **The log says so once per route**, naming the realm or the header: `… dsh-opencode-patch leaves the route opencode to pi-ai`. - **A shared key name is read by both.** Only the credentials service is split into realms; the launch environment is not. `OPENCODE_API_KEY` (or `OPENCODE_GO_API_KEY`) exported in the environment is read by this plugin's meter and your own planes, and by `llm-pi-ai` for a Console route that names no key of its own. Keep the workspace token under the name the Console's setup gives it, which this plugin never reads. +- **The workspace's policies gate every turn and tool call**, this plugin's planes included: what the Console's workspace allows or blocks applies to a turn whichever plugin serves its route. +- **The Console's setup takes an exported `OPENCODE_API_KEY` as its own credential**, ahead of its sign-in. If you use both, do not have `OPENCODE_API_KEY` exported in the shell that runs the Console's setup, or give the planes a key reference of their own (`apiKeyEnv` naming another variable, such as `OPENCODE_ZEN_API_KEY`). With a reference of their own, the Go meter's Zen-credit check still reads `OPENCODE_API_KEY` until a Zen turn has run, so it shows Zen credit as not configured until then. ## 🛠 Troubleshooting | Symptom | Likely cause | Fix | | :-- | :-- | :-- | -| `403 FreeTierError` on free models | The gateway's gate is not in the request: measured with a real account, its session token, its active org and every header the official client sends, a free-tier model still answers `403` | The three injection switches above are still worth keeping on — they are what makes the traffic identifiable and they restore the tool schemas — but they do not open the free tier on their own. The paid models work with a key; see [docs/opencode-1.19-api.md](./docs/opencode-1.19-api.md) for what was excluded and how | +| `403 FreeTierError` on free models | The gateway's gate is not in the request: measured with a real account, its session token, its active org and every header the official client sends, a free-tier model still answers `403` | The three injection switches above are still worth keeping on — they are what makes the traffic identifiable and they restore the tool schemas — but they do not open the free tier on their own. The paid models work with a key; see [docs/opencode-1.19-api.md](./docs/opencode-1.19-api.md) for what was excluded and how. On a plane this plugin serves, such a turn fails as `FREE_TIER` with the gateway's words, and its status except on Google-protocol models, whose failure text carries none | | `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: name `apiKeyEnv: OPENCODE_GO_API_KEY` on the `opencode-go` entry and store that key in DSH Credentials | @@ -444,6 +446,7 @@ OpenCode's own Console plugin (`npx @opencode/console setup deepseek-harness`) c - **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. Every supported host's pi, on npm installs and on both channels' desktop apps, is `1.3.0` or later and names that model, so its turns carry the level; on a pi `1.1.0` host (the `0.2.1` rc desktop app, no longer a supported channel build) every `gemini-4-argon` turn carried a thinking budget the gateway refuses. - **`@viztor/dsh-opencode` is deprecated, and still published.** It is a thin wrapper that re-exports `dsh-opencode-patch`, so an install on the old name keeps resolving; the registry marks it deprecated and points at the canonical name. New installs should use `dsh-opencode-patch` or `@viztor/dsh-opencode-patch`. - **A route another plugin owns is left to it.** A plane entry in `llm-pi-ai`'s row is served by `llm-pi-ai`, not by this plugin, when that row resolves credentials in a realm this plugin does not or the entry carries the `x-opencode-plugin` header — the shape OpenCode Console's setup writes — and no header, key or meter of this plugin's touches its traffic. In the released 1.x, a turn on such a route that the plugin moved onto one of its hidden Responses, Messages or Google routes was resolved with this plugin's credentials and failed for want of the Console's token, and every other turn on it carried this plugin's headers and had its key recorded for the meter; see [With OpenCode Console installed](#with-opencode-console-installed). +- **The gateway's refusals are reported as what they are.** On the planes this plugin serves, a `FreeTierError` (403) or `FreeUsageLimitError` (429) now finishes the turn as `FREE_TIER`, and a `RegionError`, `ModelDeprecated` or `ModelProtocolUnsupported` as `MODEL_UNAVAILABLE`, each with the gateway's words, and its status except on Google-protocol models, whose failure text carries none. The conversation used to show "API key is invalid" for the 403s and "Request quota exhausted." for the usage cap, with the gateway's message dropped. Like the codes they replace, neither new code is in the host's retryable set, so a refused turn is still not resent. A free Chat Completions turn with no tools of its own now carries `tool_choice: none` beside the free tier's `read` and `bash`, and a `read` or `bash` declared with an empty schema is replaced by the full one. ## 📜 Compatibility & Verification diff --git a/README.zh-CN.md b/README.zh-CN.md index f00bbcfd..e1de0dc1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -229,7 +229,7 @@ Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在 | **网关请求** | 注入客户端来源头(默认开启) | `on` | 在网关流量上注入 `x-opencode-client`(以及来源请求头集合) | | **网关请求** | 附带工作区项目标识(默认开启) | `on` | 用当前文件夹名标记 `x-opencode-project`;关闭则省略该请求头 | | **模型与免费额度** | 用 OpenCode 官方目录补全列表(默认开启) | `on` | 让网关 `/models` 列表带上完整目录,并把目录提供给设置页的模型发现。本身**不会**往选择器里添加模型——选择器显示你声明的模型;条目未声明任何模型时显示该平面的目录,此开关不改变这两者。要声明更多,请在 OpenCode 平面上使用 **Settings → Models → Fetch**:它会提供 pi 的列表以及 pi 缺少的目录模型,选用一行并保存即完成声明。已声明模型自身的信息(上下文、模态、思考档位)始终来自你的模型行与目录,与此开关无关 | -| **模型与免费额度** | 自动补全核心工具(默认开启) | `on` | 补上免费层 `/responses` 请求体所需的 `read` + `bash` schema | +| **模型与免费额度** | 自动补全核心工具(默认开启) | `on` | 补上免费层 `/responses` 与 Chat Completions 请求体所需的 `read` + `bash` schema,替换以空 schema 声明的 `read` 或 `bash`,并给没有自带工具的免费 Chat Completions 请求体加上 `tool_choice: "none"` | | **配额计量** | 开启输入区用量计量(默认开启) | `on` | 在输入框停靠栏挂载实时计量表——Go 额度环,或 Zen 会话消耗 | | **账户与端点** | API 端点 | `auto` | 请求发往哪个端点家族:**自动**(同新端点) · **旧端点**(`zen/v1`、`zen/go/v1`) · **新端点**(按协议的 `inference/{openai,anthropic,google}/…`,Go 为 `inference/go/…`) | | **账户与端点** | 凭据来源 | `auto` | 多个密钥同时可用时以哪个为准:**自动** · **优先实时请求** · **优先已声明密钥** | @@ -405,12 +405,14 @@ OpenCode 官方的 Console 插件(`npx @opencode/console setup deepseek-harnes - **在 Console 的路由上,Settings → Models → Fetch 就是 `llm-pi-ai` 自己的列表。** 不会往里添加任何目录模型;`llm-pi-ai` 重启期间,获取会像 `llm-pi-ai` 自己那样失败,而不是回答目录;与之并列的你自己的平面条目仍会得到目录中的模型。 - **日志对每条路由说明一次**,并指出是凭据域还是请求头:`… dsh-opencode-patch leaves the route opencode to pi-ai`。 - **同名的密钥两边都会读到。** 只有凭据服务按域隔离,启动环境不隔离。环境中导出的 `OPENCODE_API_KEY`(或 `OPENCODE_GO_API_KEY`)会被本插件的计量和你自己的平面读取,也会被 `llm-pi-ai` 用于没有指定自己密钥的 Console 路由。请把工作区令牌保留在 Console 安装步骤给它的名字下,本插件从不读取这个名字。 +- **工作区的策略约束每个回合和每次工具调用**,包括本插件的平面:Console 工作区允许或禁止的内容,对一个回合都适用,无论哪个插件服务它的路由。 +- **Console 的安装步骤会把导出的 `OPENCODE_API_KEY` 当作它自己的凭据**,优先于它的登录。如果两者都用,请不要在运行 Console 安装步骤的 shell 中导出 `OPENCODE_API_KEY`,或给平面一个自己的密钥引用(让 `apiKeyEnv` 指向另一个变量,例如 `OPENCODE_ZEN_API_KEY`)。使用自己的引用时,Go 计量的 Zen 余额检查在 Zen 回合运行之前仍读取 `OPENCODE_API_KEY`,所以在此之前会显示 Zen 余额未配置。 ## 🛠 故障排查 | 症状 | 可能原因 | 解决方法 | | :-- | :-- | :-- | -| 免费模型报 `403 FreeTierError` | 网关的门槛不在请求里:用真实账号、它的会话令牌、它的当前组织以及官方客户端发送的全部请求头实测,免费层模型仍返回 `403` | 上面三个注入开关(**恢复 User-Agent**、**注入客户端来源头**、**自动补全核心工具**)仍值得保持开启——它们让流量可被识别,并恢复工具定义——但单靠它们打不开免费层。付费模型配上密钥即可使用;排除了什么、如何排除,见 [docs/opencode-1.19-api.md](./docs/opencode-1.19-api.md) | +| 免费模型报 `403 FreeTierError` | 网关的门槛不在请求里:用真实账号、它的会话令牌、它的当前组织以及官方客户端发送的全部请求头实测,免费层模型仍返回 `403` | 上面三个注入开关(**恢复 User-Agent**、**注入客户端来源头**、**自动补全核心工具**)仍值得保持开启——它们让流量可被识别,并恢复工具定义——但单靠它们打不开免费层。付费模型配上密钥即可使用;排除了什么、如何排除,见 [docs/opencode-1.19-api.md](./docs/opencode-1.19-api.md)。在本插件服务的平面上,这样的回合以 `FREE_TIER` 失败,并带有网关的原话,以及它的状态码(Google 协议的模型除外,其失败文本不带状态码) | | `400 MissingSessionID` | 未附带会话请求头 | 确认 `dsh-opencode-patch` 已列入 profile 的 `dsh.profile.bundles` | | Auto Review 报 `TRANSPORT: Connection error` | 更新后宿主进程未重启 | 停止并重启 `dsh web`,让新的 `lib/index.mjs` 加载 | | 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 中存储该密钥 | @@ -438,6 +440,7 @@ OpenCode 官方的 Console 插件(`npx @opencode/console setup deepseek-harnes - **Google 思考控制由宿主的 pi 自己决定。** Gemini 请求携带思考**档位**还是 token **预算**,由宿主的 pi 决定,插件不再自行推导。1.4.3 发布的改写(#130)——在 pi 未列出 `gemini-4-argon` 的宿主上为它发送档位——已移除。所有受支持宿主的 pi(npm 安装以及两个通道的桌面应用)都是 `1.3.0` 或更高版本,会列出该模型,因此其回合携带档位;在 pi 为 `1.1.0` 的宿主(`0.2.1` 的 rc 桌面应用,已不再是受支持的通道构建)上,每个 `gemini-4-argon` 回合携带的都是网关会拒绝的思考预算。 - **`@viztor/dsh-opencode` 已弃用,但仍在发布。** 它是一个转导出 `dsh-opencode-patch` 的薄包装,所以用旧名的安装照常解析;registry 上标记为弃用并指向正式名称。新安装请用 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。 - **其他插件拥有的路由留给它自己。** 当 `llm-pi-ai` 的配置行在本插件不解析凭据的凭据域中解析凭据,或者平面条目带有 `x-opencode-plugin` 请求头时(OpenCode Console 安装步骤写入的正是这种形状),该平面由 `llm-pi-ai` 而不是本插件服务,本插件的请求头、密钥与计量都不碰它的流量。在已发布的 1.x 中,这类路由上被本插件转到其隐藏的 Responses、Messages 或 Google 路由的轮次用本插件的凭据解析,因拿不到 Console 的令牌而失败,其余轮次则带上本插件的请求头,其密钥也被记录下来供计量使用;见[与 OpenCode Console 同时安装](#与-opencode-console-同时安装)。 +- **网关的拒绝如实报告。** 在本插件服务的平面上,`FreeTierError`(403)或 `FreeUsageLimitError`(429)现在以 `FREE_TIER` 结束回合,`RegionError`、`ModelDeprecated` 或 `ModelProtocolUnsupported` 以 `MODEL_UNAVAILABLE` 结束,各自带有网关的原话,以及它的状态码(Google 协议的模型除外,其失败文本不带状态码)。过去对话对这些 403 显示“API 密钥无效”(API key is invalid),对用量上限显示“当前请求的额度已用尽”(Request quota exhausted.),网关的消息被丢弃。与被替换的代码一样,这两个新代码都不在宿主的可重试集合中,所以被拒绝的回合仍不会被重发。没有自带工具的免费 Chat Completions 回合现在在免费层的 `read` 与 `bash` 之外带上 `tool_choice: none`,以空 schema 声明的 `read` 或 `bash` 会被完整的定义替换。 ## 📜 兼容性与验证 diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 9d269b4b..36bb37a4 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -29,11 +29,11 @@ Phases land as **stacked pull requests**. The open ones are rebased, each onto i | P-H | #112 | `fix:` disabling or reloading the plugin restores the host methods it wrapped | merged (1b4ebf0) | **merged P-T1b**, rebased onto it as its own change; gains the Chinese "Changed in 1.x" bullet beside the English one (the pairing gate); the helper, its sites and its tripwires are unchanged; retitled from `refactor:`, because the restore on a disable or a reload is a defect fix the README announces and the changelog must carry (a `refactor` is hidden from it) | | P-R1 | #113 | `feat:` the plugin owns the plane routes | merged (c7820b4) | **rebased onto `main` 1.9.2** (P-H merged) as its own change; on both channels' cells, one row table every cell shares (the plugin places, describes and routes every declared id itself); deletes the Gemini level wrapper (pi's own rule, from pi 1.3, which both floors run), reads no base or protocol from pi's catalog, and sets no default window or max tokens, pi-ai applies its own (D16, D17, D26); the route claim speaks both cordis cores' `internal/config` call shapes, so it engages on both channels' newest builds (the binary e2e's `[KNOWN_DEFECT]` row is a `[BASELINE]` now), and the plane profiles carry pi-ai's image-count bounds where its `Config` fills them | | P-R2 | #114 | `test:` the endpoint families, measured | merged (08088aa) | **rebased onto P-R1** as its own change; its dispatch set holds on every host cell, re-measured on the new host core where the claim now engages (both channels' newest builds and floors) and on the old one; gains the L3, L4, L5, L9, L10 and L13 records and the per-request form of `main`'s mock-coverage gate | -| P-R2b | | `fix:` flip the default on §13's evidence | if needed | P-R2's first CI run decides it | +| P-R2b | | `fix:` flip the default on §13's evidence | not needed | P-R2's first CI run, [run 43542273415](https://github.com/viztor/dsh-opencode-patch/actions/runs/43542273415) (2026-11-14), shows no flip evidence: every keyed Zen cell answered the account's 403 on every shape, Go's completions model was retired on every shape, and Go's other modern cells were served; the default stays `modern`. Its keyless and keyed tables went into §13 with P-R3, which is the `docs:` half this row would have closed with | | P3 | #115 | `feat:` Settings complements pi's list | merged (2f474b2) | **rebased onto P-R2** as its own change; the complement is gated on a claim that has engaged (`RouteClaim.claiming()` reads false once the mount settles with the served pi-ai row still resolving a plane and no restart of it in flight, which on either host core is only a call shape the listener does not speak; it reads true through a handover's window, while the newly served row's restart is in flight, so the fetch is complemented until the row unloads and the catalog alone while it does; a throwing restart leaves pi-ai FAILED and the claim engaged), so Settings' fetch is pi's own list only there; the binary e2e's Settings fetch rows run in every cell of both channels, the order read against the list the prefix's own pi exports | | P-T2 | #116 | `test:` suite structure | merged (9be88d6) | **rebased onto P3** as its own change; every test `main` and the lower restack brought is in this layout (the split files, the one door, and no socket in the unit run, which `test/architecture.test.ts` now enforces); the catalog tests assert invariants, and the cases this phase and `main` brought, `tool-fallback`'s free model and `models-discovery`'s catalog rows pick rows by shape or seed them, while earlier cases that name a vendor id, as a declared profile entry, a fixture string or a lookup in the shipped tables (`go-limits`' allowance coverage, `thinking-levels`' Gemini rows, `plane-model`'s `specOf`), keep it (converting those is a follow-up); `test/e2e/verdict.ts` folds into `test/support/live-probe.ts`, `main`'s mock-coverage cases join `protocol-endpoints.mock.ts`, the host-drift CLI case moves to the mock suite, `main`'s plane-lookup fallback gains its first assertions, and `docs/testing.md` describes this layout | | P-T3 | | `ci:` CI and scripts | in review | **on `main` (9be88d6)**, which P-T2's merge left with this phase's base tree; shrank to what `main` had not done (one unit run, the build before `lib/`, tsx type-checked, one setup action, `check.ts` trimmed, `verify-*` exit codes, `.github/` and `*.sh` scanned) and gained the dist-tags and 2.x User-Agent read (L6) and the nightly bump proposal (an open proposal left to its reviewer); the checkout stays in each job, since a local action cannot bring its own, and the `check` job keeps its required-check name and the default install's coverage run | -| P-R3 | | `fix:` honest refusals | planned | new | +| P-R3 | | `fix:` honest refusals | in review | **new, on P-T3**, after P-R2's re-measure (run 43542273415): the gateway's typed refusals finish as `FREE_TIER` or `MODEL_UNAVAILABLE` on the planes, with the gateway's words and, except on Google, its status (L2, the UI claim verified on both channels' chat UI); L7's `tool_choice: none` and schema check; the OC6, OC8 and OC9 probe rows and OC10's README bullets. L9 and L10 land no code: the run answered no keyed 429, and left L10 unmeasured (the reasoning model was refused with and without the field, so the field was never read) | | P5 | | `refactor:` installers | planned | | | P6 | | `refactor:` the fetch patch's credential fill | planned | | | P7 | | `refactor:` one source per table | planned | | @@ -423,8 +423,8 @@ What stays private is pinned by contract tests against the real host packages: - **The flip rule.** A keyed modern cell that answers path-gone, wrong-protocol or auth-refused with the CI key turns the job red, and so does a modern model-gone beside another shape of the same plane × protocol (same model, same key) that accepted it (`served` or `path-exists`). Flip evidence is a wrong-protocol, path-gone, auth-refused or model-gone on the modern cell while the cell a flip would send the same plane × protocol to (`LEGACY_DISPATCH`'s, when it is not the modern cell itself — never Go's Google) accepted the request (`served` or `path-exists`; beside a refused key or a gone path `account` counts too, as it shows the key read and the path there, but not beside a refused protocol or model, as the account's gate answers before either is read): the evidence for P-R2b, the `fix:` that flips `AUTO_ENDPOINT_FAMILY`, with the copy, `TODAY_DISPATCH` and the re-tagged wire facets. A refusal only the root or Go Google's `/zen/go` cell accepted is red but not flip evidence (no flip reaches either); so are a key refused on every shape that read it (the CI secret), a key no other shape read, and a protocol or a path the flip's cell refused too (a flip would not serve it). Each sibling counts only for what it measured: a protocol or a path the flip's cell answered nothing about (a rate limit, a busy upstream, the account's gate, no answer) is red, not evidence, and a re-run. A busy upstream never counts as a shape accepting the request. The job also fails when a default cell answers nothing readable (`other`, a redirect, a refused session, `transport-error`, `no-model`), which is not flip evidence, and skips a plane none of whose default cells was served (a busy upstream — a 503, a 529 or a 5xx naming an overload — skips like a rate limit; a 500 or 502 stays `other`). - **Restacked onto 1.8.0 (the lessons from opencode2dsh).** Merged P-R1, so the dispatch set holds on every host cell (the plugin builds every base from its own endpoint table, D26), and `ModelDeprecated` reads as a withdrawn model in a turn's failure message and the live walk's raw probe (P-R1's `isModelGoneMessage` and `modelGone`); P-R2 extends the type to its own probe classifiers — keyless, free and keyed (`isModelDeprecated`) — so a refusal of that type at any status is `model-gone`, a retired free model moves the free probe on rather than failing it, and a keyed 404 or 400 of that type is never a gone path or a shape that accepted the model. Added, all recorded and none asserted: the free probe's `429 FreeUsageLimitError` is its own verdict, `free-usage-limited`, a skip that is never retried and never read as served (L3; the root family L3 asks for was already a shape); the System One family's three shapes on the Zen plane, keyless (L13); the keyed variants of each plane's default completions cell, a minted `x-opencode-request` (L4), `x-opencode-project` as `global`, a folder name and a 40-hex digest (L5), and `reasoning_effort: "none"` beside no effort on a paid completions model that reasons (L10); and every keyed 429's `Retry-After` (L9). `main`'s live-run verdicts (`test/e2e/verdict.ts`) read the usage cap as the free tier closing, not a busy gateway, and the keyed free-tier walk in `protocol-routing.e2e.ts` skips naming the cap when any candidate met it and none met a busy gateway (`freeWalkGap`: busy, then capped, then closed), since a capped candidate says nothing about the tier, rather than reporting a session gate as the reason; a session gate another candidate met is still named in the skip reason (`freeWalkCappedReason`). The combined Google User-Agent question closed offline rather than live: the fetch patch replaces the User-Agent on the request every protocol sends, Google's included, and the mock pins Google's wire value to `OPENCODE_UA` on both planes (`test/mock/protocol-compat.mock.ts`), so the gateway never sees a combined value (`docs/redirect-planes.md` §9); it reopens only if P10a moves the agent into call options. `main`'s mock-coverage gate (#142) gains its per-request form over `TODAY_DISPATCH ∪ LEGACY_DISPATCH`, with every root cell required to answer `no_route`. - **Rebased onto P-R1 (`main` 1.9.2), on the new host core.** The dispatch set was re-measured where the route claim now engages on cordis's newer `internal/config` call shape: the published binary's probe turns (twelve rows, both planes) reach exactly `TODAY_DISPATCH`'s eight modern cells, with both planes on the plugin's `PlaneAdapter`, on every `e2e-host` cell — `rc` `0.2.2-rc.2` (cordis `4.1.0`) and `alpha` `0.2.2-alpha.3` (cordis `4.0.6-alpha.2`), each with its newest pi (`1.5.2`) and its floor (`1.3.0`) — and on the old core (`0.2.1-rc.5`, cordis `4.0.5`), where the binary e2e skips the wire row because its pi (`1.5.0`) is not the record's, so only the probe shows it; tier B derives the same set on both channels' installs. Nothing in the probes, the classifiers or the dispatch tables had to move: the plugin builds every base from its own endpoint table (D26), so which core dispatches the claim does not change where a turn goes. - - **Measured results** live in `docs/redirect-planes.md` §13, pending the first CI run. - - **The follow-up is P-R2b**, conditional: it pastes the first CI run's tables into §13, and flips `AUTO_ENDPOINT_FAMILY` only if that run (or a later one) shows flip evidence; with none, it closes as a `docs:` change once §13 holds a real run. + - **Measured results** live in `docs/redirect-planes.md` §13: the first CI run's keyless and keyed tables and its free-model, System One and `Retry-After` lines ([run 43542273415](https://github.com/viztor/dsh-opencode-patch/actions/runs/43542273415), 2026-11-14), pasted with P-R3. Its four L10 rows are quoted in P-R3's entry below rather than pasted, since §13's variants table holds the formatter's current rows, the console study's included; that table waits for the first run on that formatter. Its other eight lesson rows (L4, L5) sent the keyed table's representative completions models and read `account (403)` on Zen and `model-gone (410)` on Go, as those cells did, so they measured nothing for L4 or L5 either. + - **The follow-up was P-R2b**, conditional: it pastes the first CI run's tables into §13, and flips `AUTO_ENDPOINT_FAMILY` only if that run (or a later one) shows flip evidence; with none, it closes as a `docs:` change once §13 holds a real run. The first run showed none, and P-R3 pasted its tables, so P-R2b is not needed. 9. **P3 `feat:` Settings complements pi's list.** In review. Landed as: - the `discoverModels` wrapper (`src/models-discovery.ts`, through `host/patch-method.ts`): on a plane the plugin serves, pi's rows first in pi's order (ids the OpenCode catalog has retired dropped once a live catalog read has landed — offline, pi's list passes through; each field read from the catalog first and from pi only where the catalog has none, the composer's own D17 order, so a row adopted from the fetch carries what the composer serves), then every servable, non-deprecated catalog model pi lacks (`complementDiscoveredModels`), each id once and whitelisted to `LlmDiscoveredModel`'s five fields; `NO_DISCOVERY` from pi-ai's namespace answers the catalog alone (F21); - `installModelDiscovery`, `toDiscovered`, the `registerModelDiscovery` namespaces and their lifecycle tests deleted; @@ -450,11 +450,23 @@ What stays private is pinned by contract tests against the real host packages: - **`check.ts`** keeps the `lib/` freshness check (once, `libFreshness`), the consumer install (loading the client half too, through `exports["./client"]`) and the size tripwire. Its file checks are unit tests: `ci-wiring` (the workflows, parsed), `package-checks`, `toolchain`, `no-local-paths` (now over `.github/` and `*.sh`, with Homebrew's prefixes and the credential scan). - **Scripts:** the generators share `generator-kit.ts` (arguments, source read, the one staleness rule over data) and the catalog's pure half (`catalog-shim.ts`), so a moved plane or family endpoint is stale; `verify-catalog` fails on a registry it could not read unless `--offline`, and `verify-session-band` exits 1 when a fact disagrees (the verdict is `bandVerdict`, its pure half's). The limits renderer is a pure module (`go-limits-render.ts`) whose text is the committed file, so `--write` also applies a template change on current data. `cli:version` reads the registry's dist-tags and refuses a 2.x-form User-Agent no constant pins (L6). - **Harness bumps:** the nightly `host-drift` job proposes the bump (`scripts/propose-host-bump.ts`) as `chore/host-bump`, moving the version data CONTRIBUTING's release step 3 lists and nothing else; the pull request's own CI runs the tiers and the binary e2e, and it dispatches ci.yml for the floor cells when `piFloor` or a family field moved (D22''). It proposes nothing from a partial read, **waits a night** for a cut inside pnpm's one-day release-age window rather than lifting the window, never edits a measured table (the red rows are the reviewed re-measure, named in the body), and opens a cut that starts a new prerelease tuple, or sits under a dist-tag the record type does not admit, as a range change for review. A dist-tag moves only with `dsh`, so the plan is current exactly when the report prints nothing for the channel. **An open proposal with the same target is left alone** (no push, no edit, no dispatch; the target is the marker line the body ends with, every proposed record in full, since the title names only each record's headline value), and a newer target rebuilds the branch only while every commit on it is the nightly's own (the bot as author and as committer, so a reviewer's amend or rebase counts as the reviewer's; only this repository's own branch is the proposal, never a fork's of the same name), since the re-measure, a shared README sentence and a range plan's peer clause are a reviewer's commits there. Both lockfiles hold one pi, so a proposed pi move carries every record: a channel waiting only on its own young cut takes a pi-only move when its recorded pi range admits the pi, and one that cannot holds the pi move. The plan is written in a step with no token in its environment, which holds because the install runs none of the cut's code (nothing approves a dependency build script: no `allowBuilds` entry, `build` permission or allow-all in `pnpm-workspace.yaml`, no `PNPM_CONFIG_*` variable and no CLI flag in the step, the proposer or the rc relock, and none of the older spellings pnpm 12 ignores); a second step pushes it, and the floor-cell dispatch passes the `keyed` input as `none`, so it spends no key. -12. **P-R3 `fix:` honest refusals.** After P-R2 has recorded the shapes (lessons L2, L7, L9, L10 below). - - `PlaneAdapter.prepareCall` classifies the gateway's refusals as non-retryable and keeps their status and body: `FreeTierError` (403) and `FreeUsageLimitError` (429) as the free tier's refusal; `RegionError`, `ModelDeprecated` (410) and `ModelProtocolUnsupported` (400) as "model unavailable". First verify whether an `AUTH` code makes the conversation UI replace the gateway's message, which decides where the codes land. - - Set `tool_choice` when the gate tools are injected (`none` on chat completions with no original tools; never on Responses, which accepts only `auto`), and treat a `read`/`bash` tool with empty `properties` as missing. - - Fill dsh-llm's `providerRetryAfterMs` from `Retry-After`, if P-R2 measured it on Go's 429s. - - Offer Off through `reasoning_effort: "none"` on plain chat-completions reasoning models, if P-R2 measured it. +12. **P-R3 `fix:` honest refusals.** What landed, after P-R2's re-measure (lessons L2, L7, L9, L10 and the console study's OC6, OC8, OC9, OC10): + - **The UI evidence decided the codes.** FB's claim holds on both channels' published chat UI (`@deepseek-ai/dsh-client-ui-chat` `0.2.2-alpha.3` and `0.2.2-rc.2`, `failureMessage` and `displayFailure`; host source `MessageItem.tsx` and `event-projection.ts`): an AUTH failure's message is blanked and the conversation shows "API key is invalid"; QUOTA and ACCOUNT_QUOTA show "Request quota exhausted."; `failureMessage` replaces three more codes with words of its own (ACCOUNT_SIGNED_OUT, INTERRUPTED and ACCOUNT_SIGN_IN_REQUIRED), six in all; every other code shows the failure's own message. The UI is not a dependency of this repository, so this was read off the published bundles and the host source, outside the suite; `test/mock/plane-refusals.mock.ts` is the behavioural check of the codes a turn finishes with. The bridge read the gateway's refusals as AUTH (`FreeTierError` 403, `RegionError` 403), QUOTA (`FreeUsageLimitError` 429, by its words "usage limit reached", on every protocol, Google's included), PI_AI_ERROR (`ModelDeprecated` 410, and every other Google refusal, whose text carries no status) or INVALID_REQUEST (`ModelProtocolUnsupported` 400). So the planes finish them with two new codes, neither one of the six the UI replaces and neither in dsh-llm's default retryable set (EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT): `FREE_TIER` for `FreeTierError` and `FreeUsageLimitError`, `MODEL_UNAVAILABLE` for `RegionError`, `ModelDeprecated` and `ModelProtocolUnsupported`, each keeping pi's text (the gateway's body) and the status at its head. + - **The adapter wrapper.** `src/utils/gateway-refusal.ts` (pure, at 100%) reads the type token out of pi's failure text and the status off its head in pi's four shapes (`403: {…}`, `opencode API error (403): {…}`, `403 {…}`, and Google's bare body, which carries no status: pi's gap, not filled, D26). `PlaneAdapter.prepareCall`'s stream and `PlaneAdapter.stream` rewrite only the terminal error finish whose failure names a type, as a copy; every other chunk passes as the same object, and a consumer that stops early still closes pi-ai's stream. pi-ai's own routes and a plane stepped aside for another plugin (D27) keep the bridge's codes; nothing on the wire changes (the fetch patch still passes every refusal through untouched). + - **L7.** A `read`/`bash` whose schema has no `properties` counts as missing and is replaced, never kept beside the full one; a free chat-completions body that brought no tools of its own gets `tool_choice: "none"` beside the injected pair; `/responses` never gets a `tool_choice`. + - **The probe rows.** `KEYED_VARIANTS` gains OC9 (a raw session id in the shape the host mints, `session-`, with a tail outside the `ses_` band so a pass cannot come from the tail; keyed and free), OC8 (the session-only header set, through an `omit` list), OC6 (the harness's `User-Agent` from `attributionHeaders()`, keyed and free), and a free baseline: the free rows are sent with no key and the free turn's own body. Recorded in §13's variants table, never asserted; deciding on them is an owner's D-row (OC6), the derivation's (OC9) and P6's (OC8). The free baseline is also L7's pending live evidence: it sends `tool_choice: "none"` beside the injected pair, which the mock gateway does not judge, so read it before a release: a `path-exists` (a 400 about the body) or `other` there, on a plane with a free model, points at L7's `tool_choice` first, while `auth-refused`, `free-usage-limited` (the free tier's usage cap, its own keyed verdict since this phase, never `rate-limited`, L3) or `rate-limited` mean the gateway answered before it judged the field, and `session-refused` (a `FreeTierError`) cannot tell a gate that stopped counting the tools from one that refuses the request outright, so after any of them L7 is still unmeasured (§13 says where the row's body is). + - **L9 and L10, not landed.** [Run 43542273415](https://github.com/viztor/dsh-opencode-patch/actions/runs/43542273415) (a `full`-tier dispatch on `main`, 2026-11-14) printed "`Retry-After` on a keyed 429: no 429 was answered.", so there is no `Retry-After` to fill (the line is in §13). L10 is **unmeasured**, not refused: the reasoning model itself was refused with and without the field, so the gateway never read it. The run's four L10 rows, quoted from its variants table (which predates the console study's rows, so it is not pasted into §13): + + | Plane | Probe | Lesson | Model | `/inference(/go)/openai/v1/chat/completions` | + | :-- | :-- | :-- | :-- | :-- | + | `opencode` | a reasoning model, no `reasoning_effort` | L10 | `minimax-m3` | account (403) | + | `opencode` | `reasoning_effort: "none"` | L10 | `minimax-m3` | account (403) | + | `opencode-go` | a reasoning model, no `reasoning_effort` | L10 | `mimo-v2.5-pro` | model-gone (410) | + | `opencode-go` | `reasoning_effort: "none"` | L10 | `mimo-v2.5-pro` | model-gone (410) | + + `account` measures nothing about the model or its fields (§13), and `model-gone` is an id the gateway retired while the catalog still listed it as current: the probe's slices come from `parseModelsDevCatalog`, which already drops every `status: "deprecated"` row, so no catalog predicate could have excluded it (the keyed matrix picked the same id for Go and got the same 410). The gap is that the L10 pair, like the representative-based variant rows on Go, has no fallback when the gateway answers `model-gone` for a listed id; the follow-up, for the next run and not this phase, is for the pair to move on to the next reasoning completions candidate, as the free probe does (`freeProbeMovesOn`). Zen's L10 rows will keep reading `account` with the current CI key whatever the model. Off stays as P-R1's metadata offers it until a served reasoning completions model answers the pair. + + - **A correction to L9's evidence.** pi's `onResponse` fires only after a 2xx (each protocol calls it after `.withResponse()`, and Google has no hook), so a refusal's `Retry-After` is visible only at the fetch layer: when a run shows one, the mechanism belongs there (the turn store, or OC4/OC5's per-call seam in P6), not in `onResponse`. 13. **P5 `refactor:` installers.** `installRemoteService` replaces the three mount-once copies. `mountByProvide` cannot succeed on any composition today: it constructs the service first, the services need `ctx.reflect`, and only a real `Context` (which always has `plugin`) carries it. `installRemoteService` drops the provide arm or makes it publish; `lifecycle.test.ts` pins the dead arm as `KNOWN_DEFECT P5`. 14. **P6 `refactor:` the fetch patch's credential fill.** The adapter path is already D20. - One resolver, for an empty or placeholder wire key only. @@ -506,7 +518,7 @@ How each phase changes now that it sits on `main` 1.8.0 and on both harness chan | P3 | The complement is gated on a claim that has engaged, so once a served row's restart has settled still holding a plane the fetch is pi's own list; since P-R1 speaks both host cores that is only a call shape the listener does not speak, not a channel's cell. A handover's window reads engaged while the row's restart is in flight (complemented before the unload, the catalog alone during it, as in the mount's restart), a throwing restart leaves pi-ai FAILED and the claim engaged, and its Settings fetch rows run in every `npm`/`floor` cell of both channels. | | P-T2 | Absorbs `main`'s tests and owns `docs/testing.md`; folds `test/e2e/verdict.ts` into the live-probe module; tests `main`'s plane lookup fallback. | | P-T3 | **Shrank, as landed.** `main` had extracted the scripts' pure halves and written `docs/testing.md`; P-T3 did the rest: one unit run per install (the default install's is the coverage run, in the required `check` job) with an explicit build before every `lib/` reader, `test/**/*.tsx` type-checked, one composite setup action after each job's own checkout (the checkout cannot move into a local action), `check.ts` trimmed to freshness, the consumer install of both halves and the size tripwire (its file checks moved to `ci-wiring`, `package-checks`, `toolchain` and `no-local-paths`), the generators' one staleness rule, `verify-*` exit codes, and `no-local-paths` over `.github/` and `*.sh`. **New, as landed:** the CLI-version script reads the registry's dist-tags and refuses an unpinned 2.x User-Agent literal (L6; the client-generation table stays P7's); the nightly host-drift report proposes the bump pull request, moving CONTRIBUTING's release-step-3 data, dispatching the floor cells (keyed tier off) when `piFloor` or a family field moved, waiting a night for a cut inside pnpm's release-age window, never editing a measured table, proposing a new prerelease tuple as a range change, and leaving an open proposal with the same target (its body's marker, every proposed record) to its reviewer, with pi moved on every record or none (D22''). (L13's newest-pi cell is no longer P-T3's: under D14'' each channel's default install already is the newest pi.) | -| 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. Gains the console study's probe rows: OC9 (a raw, non-`ses_` session id, per plane, keyed and free), OC6 (the harness User-Agent against the CLI one, per plane, free and paid) and, beside OC9's, OC8's recorded session-only header set. Also OC10's paired README section ("With OpenCode Console installed") on what D27 ships and tests: the routes the plugin serves beside a Console-owned entry, the workspace's policies gating every turn, and an exported `OPENCODE_API_KEY` becoming the Console's credential. | +| 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. Gains the console study's probe rows: OC9 (a raw, non-`ses_` session id, per plane, keyed and free), OC6 (the harness User-Agent against the CLI one, per plane, free and paid) and, beside OC9's, OC8's recorded session-only header set. Also OC10's paired README section ("With OpenCode Console installed") on what D27 ships and tests: the routes the plugin serves beside a Console-owned entry, the workspace's policies gating every turn, and an exported `OPENCODE_API_KEY` becoming the Console's credential. **As landed:** L2's codes (`FREE_TIER`, `MODEL_UNAVAILABLE`, chosen on the verified UI evidence) in `prepareCall` and `stream`, L7, the OC6, OC8 and OC9 rows with a free baseline, and OC10's two remaining bullets; L9 lands no code (run 43542273415 answered no keyed 429) and L10 none (unmeasured: the reasoning model was refused with and without the field). | | 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). From the console study: OC3 (capture a key only from a request the plugin decorated, and close the fill path that could reuse a captured token; the marker guard, never capturing from a request carrying `x-opencode-plugin`, landed with D27), OC4 (`prepareHeaders` on the plane adapter, so the host's `llm-pi-ai/request-headers` waterfall runs on the planes), OC5 (the plane's headers set per call through that option, not the global fetch patch), OC11 (the peer range says what the header seam needs), OC2's remainder (the marker pass-through, which landed with D27 and reads the header's presence today, moves into `src/utils/request-shape.ts`; its second rule is not adopted) and OC10's last sentence (the README's coexistence section gains that the meter and headers stay off Console traffic, once OC3's discriminator lands). | | 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 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, the SDK table row in `README.md` and `README.zh-CN.md`, `test/protocols.test.ts`'s SDK-table case title and its `piKnows` assertion, which at most stays an informational pi characterization, and `test/catalog.test.ts`'s note that pi's data files the SDK as completions) are reworded to the plugin's own routing rule, since pi's catalog no longer decides a protocol (D26). | @@ -523,15 +535,15 @@ Two community plugins solve the same problem on the same host: [`FishBottle7/ope | # | Lesson | Evidence | Phase | Verdict | | :-- | :-- | :-- | :-- | :-- | | L1 | **Shape plane traffic per call, not through the global fetch patch.** Put the derived session id, `x-opencode-client` and `x-opencode-project` into `options.headers`, and the core-tool fallback into `onPayload`, when wrapping each api in the plane provider; keep the fetch patch for URL-claimed relays, listing enrichment and P6's credential fill. Warn once when `globalThis.fetch` is no longer the patched function. | FB `packages/plugin/src/adapter/zen-adapter.ts` (`headers`, `onPayload`); FB `packages/plugin/src/pool/installer.ts` swaps `globalThis.fetch`, and our restore is identity-guarded. `docs/redirect-planes.md` claims the headers reach all four protocols; only the session header does. | P10a, P5 (the doc fix landed in P-R1, fa53d8e) | Adopt | -| L2 | **Classify the gateway's refusals as non-retryable in `prepareCall`**, keeping status and body. The host maps 403 to `AUTH` and 429 to `RATE_LIMIT`, and retries a 429 five times. | SF `lib/errors.js`; FB `packages/plugin/src/adapter/events.ts`. FB's claim that `AUTH` makes the UI print "API key is invalid" is unverified. | P-R3 | Adopt after verifying the UI claim | +| L2 | **Classify the gateway's refusals as non-retryable in `prepareCall`**, keeping status and body. The host maps 403 to `AUTH` and 429 to `RATE_LIMIT`, and retries a 429 five times. | SF `lib/errors.js`; FB `packages/plugin/src/adapter/events.ts`. FB's claim that `AUTH` makes the UI print "API key is invalid" is verified on both channels' `dsh-client-ui-chat` (`failureMessage`, `displayFailure`). | P-R3 | Adopted: `FREE_TIER` and `MODEL_UNAVAILABLE` on the planes | | L3 | **The free probe misreads the newer refusal.** A final 429 is always read as rate-limited; `429 FreeUsageLimitError` is the gate refusing, so it is its own verdict and is not retried. Also probe the root family `/inference/v1`. | SF `873e914` (the probe no longer counts a 429 as usable); FB `packages/plugin/src/adapter/catalog.ts` counts a 429 as a pass. | P-R2 | Adopt | | L4 | **Measure `x-opencode-request` before keeping it.** We send a minted `msg_` id, and the keyed probes omit it, so it has never been measured. | FB `packages/plugin/src/adapter/ids.ts` ("any value is refused"); SF removed it; `docs/upstream-findings.md` says 2.x dropped it. | P-R2 rows, then P6 or P10a | Measure; drop it or send it only where accepted | | L5 | **`x-opencode-project` has three conflicting claims:** the folder basename (ours, which also leaks the repository's name), `global` (FB), and a 40-hex digest (SF). | FB `packages/plugin/src/adapter/ids.ts`; SF `lib/protocol.js`. | P-R2 rows for all three, then default to `global` unless another is proven | Measure; probably change the default | | L6 | **One "client generation" table** for the User-Agent format, the header set and the session semantics. Our tree disagrees with itself about the affinity headers on plane routes, and SF ships the 2.x User-Agent format. | FB `packages/plugin/src/adapter/ids.ts`; SF `lib/protocol.js`; `src/fetch-patch.ts`; `docs/upstream-findings.md`. | P7, P-T3 | Adopt | -| L7 | **Set `tool_choice` when injecting the gate tools, and check their schemas.** Ours injects and leaves `tool_choice` alone, and matches by name only, so a title or compaction call can end in a stub-tool call. | FB `packages/plugin/src/adapter/messages.ts`; SF `lib/protocol.js`. | P-R3 (or L1's `onPayload`) | Adopt | +| L7 | **Set `tool_choice` when injecting the gate tools, and check their schemas.** Ours injects and leaves `tool_choice` alone, and matches by name only, so a title or compaction call can end in a stub-tool call. | FB `packages/plugin/src/adapter/messages.ts`; SF `lib/protocol.js`. | P-R3 (or L1's `onPayload`) | Adopted in P-R3 | | L8 | **Verify before evicting a key.** A malformed session header yields `401 Invalid API key`, and FB saw the same request refused and then accepted. Evict per plane only after one verification or a second well-formed 401. | FB `packages/plugin/src/pool/death-verify.ts`; `src/utils/session-id.ts`. | P6 | Adopt | -| L9 | **Honour `Retry-After`.** dsh-llm accepts `providerRetryAfterMs`, `dsh-llm-pi-ai` never fills it, and pi's `onResponse` exposes the headers. | SF `lib/errors.js`. | P-R2 records whether Go's 429s carry it; P-R3 fills it | Measure, then adopt | -| L10 | **Offer Off on plain chat-completions reasoning models** through `reasoning_effort: "none"`; ours offers Off only for a few body formats. | FB `packages/plugin/src/adapter/zen-adapter.ts`; `src/utils/model-metadata.ts`. | P-R2 keyed probe, then P-R1's metadata | Measure first | +| L9 | **Honour `Retry-After`.** dsh-llm accepts `providerRetryAfterMs` and `dsh-llm-pi-ai` never fills it. pi's `onResponse` exposes the headers only after a 2xx (and Google has none), so a refusal's header is visible only at the fetch layer. | SF `lib/errors.js`. | P-R2 records whether Go's 429s carry it; P-R3 fills it | Measured: run 43542273415 answered no keyed 429 (its `Retry-After` line is in §13), so nothing to fill; when one does, at the fetch layer (P6) | +| L10 | **Offer Off on plain chat-completions reasoning models** through `reasoning_effort: "none"`; ours offers Off only for a few body formats. | FB `packages/plugin/src/adapter/zen-adapter.ts`; `src/utils/model-metadata.ts`. | P-R2 keyed probe, then P-R1's metadata | Unmeasured: run 43542273415's pair was refused before the field mattered, its control as much as `"none"` (Zen account 403, Go model-gone 410, a retired id); Off stays as P-R1's metadata offers it until a served reasoning completions model answers the pair | | L11 | **Context-window overrides with dated evidence.** FB keeps measured windows for two free models that differ from the catalog's; an understated window is the overflow trap. | FB `packages/plugin/src/adapter/catalog.ts`. | P7 | Low priority; the table pattern only | | L12 | **A failed or partial refresh must not replace the maps.** | SF `lib/catalog.js`; FB `packages/plugin/src/adapter/catalog.ts`. | P10b | Adopt | | L13 | **Test against the host's newer pi**, not only the pin: unit and tier A on the newest pi the host's range resolves, not just the binary e2e; the `/zen/v1/systemone` family belongs in P-R2's families. | FB's hoisted-pi test and loader hook. | P-T1a (unit and mock on each channel's newest pi), P-T1b (tiers A and B), P-R2 (the family) | Adopt | @@ -554,7 +566,7 @@ The vendor's own first-party DSH plugin, `@opencode/console` 0.1.10 (`anomalyco/ | OC7 | **Characterize coexistence on the real host with a Console-shaped fixture.** Tier B boots the loader, which applies `isolate:` realms on every supported build, so the Console's shape can be reproduced with this repository's own code: a pi-ai row isolated into a realm, marked plane routes, and a local plugin in that realm providing `credentials`. | The loader's `isolate` (`GlobalRealm`, `'@' + this.label`); `ctx.get` following the realm on both cordis cores; `dsh-plugin.js`'s realm credentials. | Landed in P-R1 as D27 | Upheld by both skeptics, with the fixture amended: our own code, never the vendor's files, and a known answer beside it (an unmarked plane still claimed). It is `test/host-planes-foreign.test.ts`, on tier B's `realm` option. | | OC8 | **The vendor's minimal inference header set is evidence for L4 and L5.** On inference the Console sends two OpenCode headers, the raw session id and its marker, and its org header only on Console API calls. | `dsh-plugin.js`'s `requestHeaders`; the marker the setup CLI puts on each route; `x-org-id` only on the Console API client. | P-R3 probe row, beside OC9's | Not upheld as written. Survives: one recorded, never-asserted session-only row per plane (`x-opencode-session` and the User-Agent; no client, project or session-id header, and never the vendor's marker), which needs an omit list on `KeyedVariant`, read beside the free probe; a pass shows acceptance only. Dropped: the org-header half (a different header on a different endpoint), and deciding L4 and L5 from it, which P-R2's `KEYED_VARIANTS` already measure. | | OC9 | **Measure whether the gateway accepts a raw, non-`ses_` session id.** The Console and the vendor's Pi extension send the host's raw session id; the plugin derives `ses_<12hex><14base62>`, and L8 records a malformed id answering `401 Invalid API key`. | `dsh-plugin.js` (`p.set("x-opencode-session", a.sessionId)`, behind the marker); the Pi extension's `"x-opencode-session": d.sessionId`; the host's `sessionId: this.session.id`; ours, `src/utils/session-id.ts`. | P-R3 probe rows | Upheld by both skeptics, narrowed: the vendor's raw id travels only on workspace routes with the workspace token, so the row is per plane, keyed and free; the derivation stays unless the row passes on both planes and the free tier, and L8 must not read a well-formed foreign id as malformed. | -| OC10 | **Document coexistence and the shared `OPENCODE_API_KEY`.** With the Console set up, pi-ai's providers are the workspace's routes until `--remove`, and the Console reads `OPENCODE_API_KEY` first, as its own key. | The package's README ("the workspace's models replace the model providers you added"); the setup CLI's credential read (`e.env.OPENCODE_API_KEY` ahead of the stored sign-in). | A docs follow-up on D27 (the shipped, tested half), first in P-R3; the meter-and-headers sentence with P6 | Not upheld as written. Survives: a paired README section ("With OpenCode Console installed") stating only shipped, tested behaviour: which routes the plugin serves under D27, that the workspace's policies gate every turn and tool call (ours included), and that an exported `OPENCODE_API_KEY` becomes the Console's credential, so a user of both gives the planes their own reference. Not until code makes it true: that the meter and headers stay off Console traffic (OC2, OC3). | +| OC10 | **Document coexistence and the shared `OPENCODE_API_KEY`.** With the Console set up, pi-ai's providers are the workspace's routes until `--remove`, and the Console reads `OPENCODE_API_KEY` first, as its own key. | The package's README ("the workspace's models replace the model providers you added"); the setup CLI's credential read (`e.env.OPENCODE_API_KEY` ahead of the stored sign-in). | A docs follow-up on D27 (the shipped, tested half), first in P-R3; the meter-and-headers sentence with P6 | Not upheld as written. Survives: a paired README section ("With OpenCode Console installed") stating only shipped, tested behaviour: which routes the plugin serves under D27, that the workspace's policies gate every turn and tool call (ours included), and that an exported `OPENCODE_API_KEY` becomes the Console's credential, so a user of both gives the planes their own reference. Not until code makes it true: that the meter and headers stay off Console traffic (OC2, OC3). The workspace-policy and `OPENCODE_API_KEY` bullets landed in P-R3. | | OC11 | **Make the peer range say what the header seam needs.** OC4 and OC5 rely on `llm-pi-ai/request-headers`, which no 0.2.1 bridge has, while the `@deepseek-ai/dsh-typert-protocol` peer range still admits 0.2.1. | `package.json`'s `peerDependencies`; the 0.2.1 bridge carries no `request-headers`, the 0.2.2 ones do. | P6 | Split. One skeptic upheld it (a declaration, not a runtime version check, so D26 allows it); the other held that the proposed `>=0.2.2-0 <0.3.0` still admits a published build without the seam. Survives: narrow the declaration in the change that lands OC5, to a floor that has the seam, pinned beside the declaration checks in `test/harness-channel.test.ts`. | | OC12 | **Cross-check the endpoint families and the served-catalog filter against the vendor's vocabulary.** The Console derives its gateway root from a `/(go/)?(anthropic/v1, openai/v1, google/v1beta or v1)` base and serves only chat models with tools and text output. | The setup CLI's root pattern and model filter (`chunk-a2ds5vde.js`); its api map and completions compat (`chunk-xs6kvyat.js`). | None | Not upheld as written. Survives: nothing to adopt; the vendor's shapes are all among P-R2's families, and pi already agrees with it on developer role and store for our planes. At most an optional, recorded `max_completion_tokens` completions row (a gap it finds is pi's, reported upstream as a `HOST_LIMITATION`, never patched here), and a P10b filter only as an owner decision on D3. | | OC13 | **Do not adopt: writing the user's patch file, providing `credentials`, reading the Console's sign-in.** The Console rewrites the user's patch file on setup and from its sync, provides a private `credentials` service in a loader realm, and holds an OAuth sign-in in its own data directory. | The setup CLI's bundle (the `# opencode-console:start/end` block it writes); `dsh-plugin.js` (`e.provide("credentials", …)`, its sync interval and its policy hooks). | None; under "Do not adopt" | Upheld by both skeptics, with one detail corrected: the sync rewrites the block only when its content changed, not on every tick. | @@ -716,6 +728,18 @@ The vendor's own first-party DSH plugin, `@opencode/console` 0.1.10 (`anomalyco/ - `cli-version` (the dist-tags request, a pinned `latest`, a bump on the pinned major, a generation change, a refusing registry); - `host-drift-cli`'s proposal cases (a data plan, a cut inside the release-age window, a refusing registry, the `--out` files with no tracked file touched, a channel waiting on its own cut moved to the pi the other proposes, and on a scratch copy through `--root` a successful `--write` whose `files` list is exactly what changed on disk, with both records at the one pi, the installs it promises and the body's target, and one whose pnpm step fails putting every file back). +**P-R3** + +- **Unit:** + - `gateway-refusal`: the five types in each of pi's four text shapes (the status read at the head only, none for Google), every failure that names no type left alone, a failure's own status kept, a finish copied when frozen and every other chunk the same object, a consumer's early `break` closing the source; + - `plane-provider`'s refusal describe over a fake `PiAiAdapter`: the prepared stream and `stream()` both reclassify, a non-record prepared call, a non-function stream and an unmatched failure pass as they were; + - `tool-fallback`'s L7 cases: `tool_choice: "none"` on a toolless completions body (absent or empty `tools`), none on Responses or on a body with tools of its own, an empty-schema `read`/`bash` replaced in either dialect, a full pair byte-identical; + - `keyed-probes`' OC rows: each differs from its baseline only in what it measures, the free rows keyless with the injector's own body and served by the offline gateway; `endpoint-record`'s §13 holds the twelve new rows; + - `keyed-probes`' usage cap: a keyed `429 FreeUsageLimitError` reads `free-usage-limited`, a plane whose default cells are all usage-capped skips (`planeOutcome`), and `retryAfterOf` leaves the usage cap out; `endpoint-record`'s empty `Retry-After` line says no keyed 429 was answered. +- **Mock:** + - `plane-refusals`: each refusal on Zen's four protocols and Go's completions finishes with its own code, the gateway's words and (but for Google) its status, after one request, outside the plane's retryable codes; a 401 and a plain 429 keep AUTH and RATE_LIMIT; + - `request-shapes`: the toolless free completions turn carries `tool_choice: "none"`, an empty-schema `read` is replaced, a Responses turn gets no `tool_choice`. + **P6** - **Unit:** apply A, apply B, dispose A, dispose B ends at the native `fetch`, in both orders (red on the base). @@ -751,7 +775,7 @@ What spends a key is a tier, `OPENCODE_E2E_KEYED`, which a step of the `e2e` job | Run | Tier | What runs | | :-- | :-- | :-- | | a PR | `none` | the keyless cases only, no key spent: the matrix, the free probe, the System One family's three shapes (recorded), and the keyless cases of protocol-routing and opencode-live | -| a PR touching `src/host/**`, `src/utils/protocols.ts`, `src/utils/plane-model.ts`, `src/identity.ts`, `src/utils/request-shape.ts` or the probes' own inputs (the composite setup action under `.github/actions/` among them) | `probes` | adds the keyed probes: one request of a few tokens per plane × protocol × URL shape, plus six recorded variants of each plane's default completions cell (request id, project, reasoning effort) and every 429's `Retry-After` | +| a PR touching `src/host/**`, `src/utils/protocols.ts`, `src/utils/plane-model.ts`, `src/identity.ts`, `src/utils/request-shape.ts` or the probes' own inputs (the composite setup action under `.github/actions/` among them) | `probes` | adds the keyed probes: one request of a few tokens per plane × protocol × URL shape, plus the recorded variants of each plane's default completions cell (`KEYED_VARIANTS`: the lessons' request-id, project and reasoning-effort rows, and the console study's raw-session, session-only and harness-User-Agent rows, three of them on a free model sent with no key) and every keyed rate-limit 429's `Retry-After` (the keyless free rows and the free tier's usage cap left out) | | nightly | `full` | adds the full keyed suites | | dispatch | its `keyed` input (`full` when empty; `none` from the nightly bump proposal's floor-cell dispatch) | as that tier | | a push to main | `none` | keyless only | diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 8e5ee01b..76b41b0b 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -216,48 +216,48 @@ The root shape is the provider-level base `models.opencode.ai` declared for each How to read a cell. Each is the verdict, then the HTTP status: - **Keyless** (no credential; one PAID model per plane and protocol): `path-gone` is a 404, the path does not exist; `path-exists` is a 401 or 403, or a 400 that is not about the model; `model-gone` is a 410, a 400 "Model is unavailable", or a refusal of type `ModelDeprecated` at any status (read before the 404, so a withdrawn model never reads as a gone path); `redirected` is a 3xx, which no probe follows, and its cell names the path it pointed at (`redirected (308 → /…)`); `other` is anything else; `no-model` means the live catalog had no paid model for the protocol, so nothing was sent. -- **Every table** (a probe is retried once after a 429, a 5xx or no answer): `rate-limited` is a 429 on the last attempt, or a 429 and then no answer — the gateway's measured rate-limit hang; `transport-error` is no answer on the last attempt, unless an earlier one was a 429 (no answer after a 5xx or a busy upstream is `transport-error` too: only the 429 hang is measured). +- **Every table** (a probe is retried once after a 429 other than the usage cap, a 5xx or no answer): `rate-limited` is a rate-limit 429 on the last attempt, or a 429 and then no answer — the gateway's measured rate-limit hang; `transport-error` is no answer on the last attempt, unless an earlier one was a 429 (no answer after a 5xx or a busy upstream is `transport-error` too: only the 429 hang is measured); the free tier's usage cap, a `429 FreeUsageLimitError`, is `free-usage-limited` on every table, never retried and never `rate-limited`. - **Free** (one free completions model, keyless, on the default completions path): `served` or `free-tier-gated` (a `403 FreeTierError`: the free tier parsed the request) pass; `rate-limited` skips, and so does `free-usage-limited`, a `429 FreeUsageLimitError` — the tier's usage cap refusing the caller, which is the gate's decision rather than a rate limit, so it is neither retried nor read as served (L3 in the plan's lessons); `wrong-format`, `auth-required`, `path-gone`, `redirected`, `other` (a 500 or 502 included) and `transport-error` fail; `model-gone` and `upstream-busy` (a 503, a 529 or a 5xx whose body names an overload: the free model is the one keyless request that reaches the upstream) move on to the next free model — `model-gone` being a 410, a 400 "Model is unavailable", or a refusal of type `ModelDeprecated` at any status (measured as a 400 for a model the catalog still listed); when every candidate moved on, a busy one among them skips, and the line names that busy candidate's model and status; three retired ones fail; `not run` means the run never reached the probe. -- **Keyed** (the plane's own key, in the header the protocol reads, with the identity headers the fetch patch puts on a turn, its user agent included; on Go that is `OPENCODE_GO_API_KEY`, the key a Go entry naming `apiKeyEnv: OPENCODE_GO_API_KEY` sends — README step 3 — not the Zen key a Go entry without it sends, which these probes do not measure, nor a plane entry another plugin owns, D27, which pi-ai sends with that plugin's credential and none of these headers): `served` is a 2xx; `path-exists` a 400 about the content; `wrong-protocol` a `400 ModelProtocolUnsupported`; `auth-refused` a 401 or 403 with the key; `session-refused` the request's session refused (`MissingSessionID`, or a `FreeTierError` on a paid model); `account` no funds or a model the account may not use, which shows the key was read and the path exists, but nothing about whether the path serves the protocol or the model; `path-gone` a 404 whose body names no model, and `model-gone` a refusal of type `ModelDeprecated` at any status, a 410, a 400 "Model is unavailable" or a 404 whose body names a model — Anthropic's `not_found_error` with a `model: …` message, `model_not_found`, or Gemini's `models/ is not found`; Anthropic's bare `not_found_error`, which every Anthropic 404 carries, is `path-gone` (a keyed request can reach the upstream); `upstream-busy` a 503, a 529 or a 5xx whose body names an overload — the upstream was busy, or the gateway's front answered before reading the request, so like a rate limit it neither fails nor passes a plane, and never counts as a shape accepting the request; `redirected` and `other` as keyless, a 500 or 502 included; `no-key` means the run had no key for that plane. +- **Keyed** (the plane's own key, in the header the protocol reads, with the identity headers the fetch patch puts on a turn, its user agent included; on Go that is `OPENCODE_GO_API_KEY`, the key a Go entry naming `apiKeyEnv: OPENCODE_GO_API_KEY` sends — README step 3 — not the Zen key a Go entry without it sends, which these probes do not measure, nor a plane entry another plugin owns, D27, which pi-ai sends with that plugin's credential and none of these headers): `served` is a 2xx; `path-exists` a 400 about the content; `wrong-protocol` a `400 ModelProtocolUnsupported`; `auth-refused` a 401 or 403 with the key; `session-refused` the request's session refused (`MissingSessionID`, or a `FreeTierError` on a paid model); `free-usage-limited` a `429 FreeUsageLimitError`, the free tier's usage cap, which only a keyless free row meets and which, like a rate limit, neither fails nor passes a plane (L3); `account` no funds or a model the account may not use, which shows the key was read and the path exists, but nothing about whether the path serves the protocol or the model; `path-gone` a 404 whose body names no model, and `model-gone` a refusal of type `ModelDeprecated` at any status, a 410, a 400 "Model is unavailable" or a 404 whose body names a model — Anthropic's `not_found_error` with a `model: …` message, `model_not_found`, or Gemini's `models/ is not found`; Anthropic's bare `not_found_error`, which every Anthropic 404 carries, is `path-gone` (a keyed request can reach the upstream); `upstream-busy` a 503, a 529 or a 5xx whose body names an overload — the upstream was busy, or the gateway's front answered before reading the request, so like a rate limit it neither fails nor passes a plane, and never counts as a shape accepting the request; `redirected` and `other` as keyless, a 500 or 502 included; `no-key` means the run had no key for that plane. - `*` marks a cell the default family dispatches to (`TODAY_DISPATCH`), `†` one `endpoint: legacy` dispatches to (`LEGACY_DISPATCH`), and `not sent` a cell the run did not reach. -Three records sit beside the tables, each measured and never asserted (the plan's lessons L4, L5, L9, L10 and L13; P6 and P-R3 act on them): +Three records sit beside the tables, each measured and never asserted (the plan's lessons L4, L5, L9, L10 and L13, and the console study's OC6, OC8 and OC9, whose rows feed an owner's OC6 decision, the session derivation's OC9 and P6's OC8): - **System One** (keyless, the Zen plane only, no model named): TypeSafe's classification protocol on its three shapes, `/zen/v1/systemone`, `/inference/v1/systemone` and `/inference/systemone/v1/systemone`, read as the keyless matrix reads a cell. The plugin serves no classifier, so nothing dispatches there. -- **Keyed variants** (each plane's default completions cell with its own key): the probe identity (no `x-opencode-request`, `x-opencode-project: global`) beside one change each — a minted `x-opencode-request` in the client's `msg_` shape, `x-opencode-project` as a folder name and as a 40-hex digest — and a paid completions model that reasons, sent with no `reasoning_effort` and with `"none"`. Each answer is read as a keyed cell. -- **`Retry-After`**: the header of every keyed 429, matrix and variants alike, or `absent`. +- **Keyed variants** (each plane's default completions cell with its own key): the probe identity (no `x-opencode-request`, `x-opencode-project: global`) beside one change each — a minted `x-opencode-request` in the client's `msg_` shape, `x-opencode-project` as a folder name and as a 40-hex digest — and a paid completions model that reasons, sent with no `reasoning_effort` and with `"none"`. Beside them, the console study's rows: a raw session id (the host's own `session-`, with a tail outside the `ses_` band, not a `ses_` id) under both session headers (OC9), the session-only header set, `x-opencode-session` without `x-opencode-client`, `x-opencode-project` or `x-opencode-session-id` (OC8), and the harness's own `User-Agent` in place of the CLI's (OC6); and a free completions model sent with NO key and the body the plugin's free turn sends (`read`, `bash`, `tool_choice: "none"`), as the probe identity, with a raw session id, and with the harness's `User-Agent` — the only keyless requests in the keyed tier. Each answer is read as a keyed cell, recorded and never asserted. On a keyless row, `auth-refused` means the gateway's key gate answered a request that carried no key (L8's `401` "Invalid API key" for a session id it reads as malformed among them), not that a key was refused, `session-refused` is the free tier's `FreeTierError`, and `free-usage-limited` is the tier's usage cap: a row that answers `free-usage-limited` reached the usage gate only, so it measured nothing about OC6, OC9 or L7, and a `session-refused` on the raw-session or harness-`User-Agent` free row is OC9's or OC6's answer only when the free baseline beside it answered otherwise. The free baseline is also L7's live evidence for `tool_choice: "none"`, which the mock gateway does not judge: a `path-exists` (a 400 about the body) or `other` there, on a plane with a free model, points at it first. `auth-refused`, `free-usage-limited` or `rate-limited` mean the gateway answered before it judged the field, and a `session-refused` cannot tell a gate that stopped counting the tools from one that refuses the request outright (the README records the free tier refusing even the official client's full request), so after any of them L7 is still unmeasured. The row's body is in the job log's console table, not here. +- **`Retry-After`**: the header of every keyed 429, matrix and variants alike, or `absent`. The keyless free rows and the free tier's usage cap are left out: neither is a keyed rate limit. The CI `e2e` job writes these tables to its run summary and to the `endpoint-families` artifact (`test/support/endpoint-record.ts` formats them). A run's tables are pasted here with their date and run link; they are never hand-written, and `test/endpoint-record.test.ts` holds this section to the formatter's columns and row order. -### Keyless · pending the first CI run +### Keyless · measured 2026-11-14 (UTC) · [run 43542273415](https://github.com/viztor/dsh-opencode-patch/actions/runs/43542273415) | Plane | Protocol | Model | `/zen(/go)/v1` (legacy) | `/inference(/go)/v1` (root) | `/inference(/go)/{openai,anthropic,google}/…` (modern) | | :-- | :-- | :-- | :-- | :-- | :-- | -| `opencode` | `openai-completions` | pending | pending † | pending | pending * | -| `opencode` | `openai-responses` | pending | pending † | pending | pending * | -| `opencode` | `anthropic-messages` | pending | pending † | pending | pending * | -| `opencode` | `google-generative-ai` | pending | pending † | pending | pending * | -| `opencode-go` | `openai-completions` | pending | pending † | pending | pending * | -| `opencode-go` | `openai-responses` | pending | pending † | pending | pending * | -| `opencode-go` | `anthropic-messages` | pending | pending † | pending | pending * | -| `opencode-go` | `google-generative-ai` | pending | pending | pending | pending * † | +| `opencode` | `openai-completions` | `minimax-m3` | path-exists (401) † | path-exists (401) | path-exists (401) * | +| `opencode` | `openai-responses` | `gpt-5.3-codex-spark` | path-exists (401) † | path-exists (401) | path-exists (401) * | +| `opencode` | `anthropic-messages` | `claude-opus-4-5` | path-exists (401) † | path-exists (401) | path-exists (401) * | +| `opencode` | `google-generative-ai` | `gemini-3.7-flash` | path-exists (401) † | path-exists (401) | path-exists (401) * | +| `opencode-go` | `openai-completions` | `mimo-v2.5-pro` | path-exists (400) † | path-exists (400) | path-exists (400) * | +| `opencode-go` | `openai-responses` | `muse-spark-1.3-contributor` | path-exists (400) † | path-exists (400) | path-exists (400) * | +| `opencode-go` | `anthropic-messages` | `minimax-m3` | path-exists (400) † | path-exists (400) | path-exists (400) * | +| `opencode-go` | `google-generative-ai` | `gemini-4-argon` | path-gone (404) | path-exists (400) | path-exists (400) * † | -Free model on `/inference/openai/v1/chat/completions`: pending the first CI run. +Free model on `/inference/openai/v1/chat/completions`: `flint-40b-free` → free-usage-limited (429). -System One on the Zen plane, keyless (nothing dispatches there): pending the first CI run. +System One on the Zen plane, keyless (nothing dispatches there): `/zen/v1/systemone` (legacy) → path-exists (400); `/inference/v1/systemone` (root) → path-exists (400); `/inference/systemone/v1/systemone` (modern) → path-exists (400). -### Keyed · pending the first CI run +### Keyed · measured 2026-11-14 (UTC) · [run 43542273415](https://github.com/viztor/dsh-opencode-patch/actions/runs/43542273415) | Plane | Protocol | Model | `/zen(/go)/v1` (legacy) | `/inference(/go)/v1` (root) | `/inference(/go)/{openai,anthropic,google}/…` (modern) | | :-- | :-- | :-- | :-- | :-- | :-- | -| `opencode` | `openai-completions` | pending | pending † | pending | pending * | -| `opencode` | `openai-responses` | pending | pending † | pending | pending * | -| `opencode` | `anthropic-messages` | pending | pending † | pending | pending * | -| `opencode` | `google-generative-ai` | pending | pending † | pending | pending * | -| `opencode-go` | `openai-completions` | pending | pending † | pending | pending * | -| `opencode-go` | `openai-responses` | pending | pending † | pending | pending * | -| `opencode-go` | `anthropic-messages` | pending | pending † | pending | pending * | -| `opencode-go` | `google-generative-ai` | pending | pending | pending | pending * † | +| `opencode` | `openai-completions` | `minimax-m3` | account (403) † | account (403) | account (403) * | +| `opencode` | `openai-responses` | `gpt-5.3-codex-spark` | account (403) † | account (403) | account (403) * | +| `opencode` | `anthropic-messages` | `claude-opus-4-5` | account (403) † | account (403) | account (403) * | +| `opencode` | `google-generative-ai` | `gemini-3.7-flash` | account (403) † | account (403) | account (403) * | +| `opencode-go` | `openai-completions` | `mimo-v2.5-pro` | model-gone (410) † | model-gone (410) | model-gone (410) * | +| `opencode-go` | `openai-responses` | `muse-spark-1.3-contributor` | served (200) † | served (200) | served (200) * | +| `opencode-go` | `anthropic-messages` | `minimax-m3` | served (200) † | served (200) | served (200) * | +| `opencode-go` | `google-generative-ai` | `gemini-4-argon` | path-gone (404) | served (200) | served (200) * † | ### Keyed variants · pending the first CI run @@ -269,11 +269,25 @@ System One on the Zen plane, keyless (nothing dispatches there): pending the fir | `opencode` | `x-opencode-project: <40 hex>` | L5 | pending | pending | | `opencode` | a reasoning model, no `reasoning_effort` | L10 | pending | pending | | `opencode` | `reasoning_effort: "none"` | L10 | pending | pending | +| `opencode` | a raw session id (the host's `session-`, not `ses_…`) | OC9 | pending | pending | +| `opencode` | `x-opencode-session` only: no `x-opencode-client`, `x-opencode-project` or `x-opencode-session-id` | OC8 | pending | pending | +| `opencode` | the harness's `User-Agent` | OC6 | pending | pending | +| `opencode` | a free model, no key: the probe identity and the free turn's body | OC6, OC9 | pending | pending | +| `opencode` | a free model, no key: a raw session id | OC9 | pending | pending | +| `opencode` | a free model, no key: the harness's `User-Agent` | OC6 | pending | pending | | `opencode-go` | the probe identity: no `x-opencode-request`, `x-opencode-project: global` | L4, L5 | pending | pending | | `opencode-go` | `x-opencode-request: msg_…` | L4 | pending | pending | | `opencode-go` | `x-opencode-project: ` | L5 | pending | pending | | `opencode-go` | `x-opencode-project: <40 hex>` | L5 | pending | pending | | `opencode-go` | a reasoning model, no `reasoning_effort` | L10 | pending | pending | | `opencode-go` | `reasoning_effort: "none"` | L10 | pending | pending | +| `opencode-go` | a raw session id (the host's `session-`, not `ses_…`) | OC9 | pending | pending | +| `opencode-go` | `x-opencode-session` only: no `x-opencode-client`, `x-opencode-project` or `x-opencode-session-id` | OC8 | pending | pending | +| `opencode-go` | the harness's `User-Agent` | OC6 | pending | pending | +| `opencode-go` | a free model, no key: the probe identity and the free turn's body | OC6, OC9 | pending | pending | +| `opencode-go` | a free model, no key: a raw session id | OC9 | pending | pending | +| `opencode-go` | a free model, no key: the harness's `User-Agent` | OC6 | pending | pending | -`Retry-After` on a keyed 429: pending the first CI run. +The variants table waits for the first run on the formatter that carries the console study's rows. [Run 43542273415](https://github.com/viztor/dsh-opencode-patch/actions/runs/43542273415) measured the twelve lesson rows under the formatter before them, so its variants table is not pasted here; the plan's P-R3 entry quotes its L10 pair. Its `Retry-After` line, which covers its keyed table and those twelve rows: + +`Retry-After` on a keyed 429: no 429 was answered. diff --git a/docs/testing.md b/docs/testing.md index cec94ec2..797dc1d5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -39,7 +39,7 @@ Properties the unit and mock suites keep by construction rather than by care: ### Mock: the wiring, without a vendor -`test/mock/` boots the plugin against a gateway that answers like the real one, including its refusals (`403 FreeTierError`, `400 ModelProtocolUnsupported`, `401 AuthError`), over a loopback socket. This is the layer that catches a wrong header, a wrong path or a wrong protocol — the questions no unit test can ask because they are about the seam. `protocol-endpoints.mock.ts` also holds the converse: every base and every request either `endpoint` setting can dispatch must reach an endpoint of the mock, and no root-family request may, so a case that checks headers rather than the status cannot pass on a request the mock never served. +`test/mock/` boots the plugin against a gateway that answers like the real one, including its refusals (`403 FreeTierError`, `400 ModelProtocolUnsupported`, `401 AuthError`), over a loopback socket. A plane turn the gateway refuses finishes with the refusal's own code and the gateway's words, and its status except on Google-protocol models, whose failure text carries none (`plane-refusals.mock.ts`). This is the layer that catches a wrong header, a wrong path or a wrong protocol — the questions no unit test can ask because they are about the seam. `protocol-endpoints.mock.ts` also holds the converse: every base and every request either `endpoint` setting can dispatch must reach an endpoint of the mock, and no root-family request may, so a case that checks headers rather than the status cannot pass on a request the mock never served. ### E2E: the vendor's own answer diff --git a/src/fetch-patch.ts b/src/fetch-patch.ts index ff365c01..9b849452 100644 --- a/src/fetch-patch.ts +++ b/src/fetch-patch.ts @@ -453,7 +453,8 @@ export const patchFetch = ( let requestToSend = input; // 4. Core tool schema fallback for free-tier /responses models, over the - // body read above. + // body read above; a toolless chat-completions body also gets + // `tool_choice: "none"`. let finalBody = bodyToPatch; if (bodyToPatch !== undefined) { const newBody = maybeInjectCoreTools(url, bodyToPatch, headers, { @@ -504,9 +505,15 @@ export const patchFetch = ( // A revision remapped it to 400 here, so DSH's classifier would stop reading // it as AUTH. That was the wrong trade: it rewrote a status the gateway // chose, and the user asked for the opposite — keep the 403, keep the real - // message. `classifyPiAiError` will label a 403 "AUTH", and that is the - // classifier's reading; the message under it is the gateway's and is - // accurate, which is what matters. Do not rewrite what the gateway said. + // message. Do not rewrite what the gateway said. The CODE is a separate + // matter, and not decided here: `classifyPiAiError` labels a 403 "AUTH", + // and the conversation replaces an AUTH failure's message with "API key is + // invalid" (both channels' `dsh-client-ui-chat`), so on the planes + // `PlaneAdapter` finishes the gateway's typed refusals as `FREE_TIER` or + // `MODEL_UNAVAILABLE` with these words, and this status except on + // Google-protocol models, whose failure text carries none + // (`src/utils/gateway-refusal.ts`). pi-ai's own routes keep the bridge's + // reading. // // Measured 403 shapes on this gateway, none of them about a credential: // FreeTierError "…can only be used from within OpenCode…" diff --git a/src/host/plane-provider.ts b/src/host/plane-provider.ts index e430c66a..0adc547d 100644 --- a/src/host/plane-provider.ts +++ b/src/host/plane-provider.ts @@ -6,14 +6,21 @@ * 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: + * family, the Off/On names of a toggle model, and the gateway's refusals by + * their own class. So the subclass overrides four methods: * * - `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. + * (D1). Ids and validation are pi-ai's; + * - `prepareCall` and `stream` finish a gateway refusal with its own class + * (`src/utils/gateway-refusal.ts`), keeping the gateway's words, and its + * status except on Google-protocol models, whose failure text carries + * none (L2, D26): the + * host's classifier reads a 403 as AUTH, and the conversation replaces an + * AUTH failure's message with "API key is invalid". Every other chunk is + * pi-ai's, passed through as the same object. * * 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 @@ -29,7 +36,12 @@ import { readLaunchEnvironment, } from "../cordis-context.ts"; import { resolveOpencodeStoreKey } from "../go-discovery.ts"; -import { isFunctionLike, isRecord } from "../utils/guards.ts"; +import { reclassifyRefusals } from "../utils/gateway-refusal.ts"; +import { + isAsyncIterableLike, + isFunctionLike, + isRecord, +} from "../utils/guards.ts"; import type { PlaneProviderBuilder } from "../utils/plane-profile.ts"; import { PROTOCOLS } from "../utils/protocols.ts"; @@ -65,6 +77,7 @@ interface PiAiAdapterLike { model: string, signal?: AbortSignal ): Promise; + stream(options: unknown): AsyncIterable; } /* oxlint-enable typescript/method-signature-style */ @@ -196,15 +209,31 @@ export const createPlaneAdapter = (inputs: PlaneAdapterInputs): object => { signal?: AbortSignal ): Promise { const prepared = await super.prepareCall(provider, model, signal); - return isRecord(prepared) - ? { - ...prepared, - model: relabelEfforts( - prepared.model, - inputs.effortNames(provider, model) - ), - } - : prepared; + if (!isRecord(prepared)) { + return prepared; + } + const { stream } = prepared; + return { + ...prepared, + model: relabelEfforts( + prepared.model, + inputs.effortNames(provider, model) + ), + ...(isFunctionLike(stream) + ? { + stream: (options: unknown): unknown => { + const chunks = Reflect.apply(stream, prepared, [options]); + return isAsyncIterableLike(chunks) + ? reclassifyRefusals(chunks) + : chunks; + }, + } + : {}), + }; + } + + override stream(options: unknown): AsyncIterable { + return reclassifyRefusals(super.stream(options)); } } // A bundler may rename a class it emits; the probe and the tests read this. diff --git a/src/tool-fallback.ts b/src/tool-fallback.ts index bcd25fc9..e15219d1 100644 --- a/src/tool-fallback.ts +++ b/src/tool-fallback.ts @@ -7,6 +7,15 @@ * to carry both schemas — and only when they are actually missing, so a body * that already declares them passes through byte-identical. * + * Two rules from the community plugins' measurements (L7): + * + * - a `read` or `bash` whose schema has no `properties` counts as missing: it + * is an empty shell the gateway does not accept as the tool, so it is + * replaced by the full schema, never kept beside it; + * - a chat-completions body that brought no tools of its own also gets + * `tool_choice: "none"`, so the model is not offered tools the turn never + * asked for. Responses is never given one: it accepts only `auto` there. + * * @module dsh-opencode-patch/tool-fallback */ @@ -128,29 +137,54 @@ export const DUMMY_BASH_TOOL_FUNCTION = { }, } as const; -const toolNames = ( - tools: unknown[] -): { hasBash: boolean; hasRead: boolean; isOpenAiFormat: boolean } => { +/** Whether a tool schema declares at least one parameter. */ +const hasProperties = (parameters: unknown): boolean => + isRecord(parameters) && + isRecord(parameters.properties) && + Object.keys(parameters.properties).length > 0; + +/** + * The tools a body keeps, which gate tools they already declare, and their + * dialect. A gate tool counts as declared only when its schema has + * properties (`tool.parameters` in the flat Responses shape, + * `tool.function.parameters` in the OpenAI one); an empty shell is dropped so + * the full schema replaces it. Every other entry is kept in order, unreadable + * ones included. + */ +const readGateTools = ( + tools: readonly unknown[] +): { + hasBash: boolean; + hasRead: boolean; + isOpenAiFormat: boolean; + kept: unknown[]; +} => { let hasBash = false; let hasRead = false; let isOpenAiFormat = false; - for (const tool of tools) { + const kept = tools.filter((tool) => { if (!isRecord(tool)) { - continue; + return true; } - let toolName: unknown = tool.name; + let { name: toolName, parameters }: Record = tool; if (typeof toolName !== "string" && isRecord(tool.function)) { - toolName = tool.function.name; + ({ name: toolName, parameters } = tool.function); isOpenAiFormat = true; } + if (toolName !== "read" && toolName !== "bash") { + return true; + } + if (!hasProperties(parameters)) { + return false; + } if (toolName === "read") { hasRead = true; - } - if (toolName === "bash") { + } else { hasBash = true; } - } - return { hasBash, hasRead, isOpenAiFormat }; + return true; + }); + return { hasBash, hasRead, isOpenAiFormat, kept }; }; /** @@ -212,6 +246,10 @@ export const isCoreToolModel = ( /** * Inject `read` + `bash` schemas into a free-tier completions body. * + * A declared `read`/`bash` without `properties` is replaced, never duplicated; + * a chat-completions body that had no tools of its own also gets + * `tool_choice: "none"`, and a `/responses` body never gets a `tool_choice`. + * * @param url - request URL (supports `/responses` and `/chat/completions`). * @param body - the outgoing request body. * @param headers - the mutable header bag whose `content-length` must track a @@ -273,17 +311,16 @@ export const maybeInjectCoreTools = ( return body; } const toolsProp: unknown = parsed.tools; - let tools: unknown[]; - if (toolsProp === undefined) { - tools = []; - } else if (isUnknownArray(toolsProp)) { - tools = [...toolsProp]; - } else { - // A malformed `tools` value cannot be merged with — replace it, the same - // as an absent one, so the request leaves with schemas the gateway accepts. - tools = []; - } - const { hasBash, hasRead, isOpenAiFormat } = toolNames(tools); + // A malformed `tools` value cannot be merged with — replace it, the same + // as an absent one, so the request leaves with schemas the gateway accepts. + const declared = isUnknownArray(toolsProp) ? toolsProp : []; + const hadNoTools = declared.length === 0; + const { + hasBash, + hasRead, + isOpenAiFormat, + kept: tools, + } = readGateTools(declared); const useOpenAi = isOpenAiFormat || isCompletions; const readTool = useOpenAi ? DUMMY_READ_TOOL_FUNCTION : DUMMY_READ_TOOL; const bashTool = useOpenAi ? DUMMY_BASH_TOOL_FUNCTION : DUMMY_BASH_TOOL; @@ -295,6 +332,14 @@ export const maybeInjectCoreTools = ( tools.push(bashTool); } parsed.tools = tools; + // The injected tools are the gateway's admission ticket, not the turn's: a + // completions body that asked for no tools must not be offered them, and a + // `tool_choice` it carried with no tools to choose from is replaced. + // Responses accepts only `auto`, so it is never given one: its URL names no + // `/completions`, which `isCompletions` already reads. + if (hadNoTools && isCompletions) { + parsed.tool_choice = "none"; + } const newBodyStr = JSON.stringify(parsed); headers.set("content-length", Buffer.byteLength(newBodyStr).toString()); return newBodyStr; diff --git a/src/utils/gateway-refusal.ts b/src/utils/gateway-refusal.ts new file mode 100644 index 00000000..735e9229 --- /dev/null +++ b/src/utils/gateway-refusal.ts @@ -0,0 +1,148 @@ +/** + * The gateway's refusals, given their own failure codes (L2). + * + * The gateway refuses a turn it will not serve with a typed body, and pi hands + * the host that body inside its failure text. The host's bridge classifies the + * text by status and words, and two of its readings hide the refusal from the + * person in the conversation, measured on both channels' published chat UI + * (`@deepseek-ai/dsh-client-ui-chat` `failureMessage` and `displayFailure`): + * + * - a 403 (`FreeTierError`, `RegionError`) reads as AUTH, and the conversation + * blanks an AUTH failure's message and shows "API key is invalid"; + * - "Free usage limit reached" (`FreeUsageLimitError`) reads as QUOTA by its + * words, on every protocol (Google's bare body included), shown as + * "Request quota exhausted." with the gateway's words dropped. + * + * `failureMessage` replaces six codes with words of its own: AUTH, QUOTA, + * ACCOUNT_QUOTA, ACCOUNT_SIGNED_OUT, INTERRUPTED and ACCOUNT_SIGN_IN_REQUIRED. + * Every other code shows the failure's own message, so the two codes here are + * new ones, outside those six: {@link FREE_TIER_CODE} for the free tier's two + * refusals and {@link MODEL_UNAVAILABLE_CODE} for the model's three. (The UI + * is not a dependency of this repository, so that was read off the published + * bundles and the host source, not exercised by a test here.) Neither code is + * in dsh-llm's default retryable set (EMPTY_RESPONSE, RATE_LIMIT, SERVER, + * TIMEOUT, TRANSPORT), which dsh-llm-retry reads before it retries a turn. + * + * pi's text keeps the gateway's body on every protocol, around one of four + * heads (pi 1.5.2, measured; pi 1.3.0 shares the path): completions + * `403: {…}`, Responses `opencode API error (403): {…}`, Anthropic + * `403 {…}`, and Google the body alone. Google's text carries no status, which + * is pi's gap and not filled here (D26): such a refusal gets its code and no + * status. The message is never changed. + * + * @module dsh-opencode-patch/utils/gateway-refusal + */ + +import { isRecord } from "./guards.ts"; + +/** The free tier refused the turn: `FreeTierError`, `FreeUsageLimitError`. */ +export const FREE_TIER_CODE = "FREE_TIER"; + +/** The model is not served here: region, deprecation, protocol. */ +export const MODEL_UNAVAILABLE_CODE = "MODEL_UNAVAILABLE"; + +/** The code each refusal type the gateway sends finishes with. */ +export const GATEWAY_REFUSAL_CODES: Readonly> = + Object.freeze({ + FreeTierError: FREE_TIER_CODE, + FreeUsageLimitError: FREE_TIER_CODE, + ModelDeprecated: MODEL_UNAVAILABLE_CODE, + ModelProtocolUnsupported: MODEL_UNAVAILABLE_CODE, + RegionError: MODEL_UNAVAILABLE_CODE, + }); + +/** A refusal type, as a whole token anywhere in the text. */ +const REFUSAL_TYPE = + /\b(FreeTierError|FreeUsageLimitError|RegionError|ModelDeprecated|ModelProtocolUnsupported)\b/u; + +/** + * A status at the text's head: `403: `, `403 `, or `… (403): ` before any + * body, and never a number inside the body. Only 1xx-5xx, because the host + * rebuilds an error from the failure and refuses a status outside that range. + */ +const LEADING_STATUS = /^(?:[^{(]*\()?([1-5]\d{2})(?=[\s:)])/u; + +/** + * The refusal type a failure's text names, or `undefined`. + * + * @param message - pi's failure text. + */ +export const refusalTypeOf = (message: string): string | undefined => + REFUSAL_TYPE.exec(message)?.[1]; + +/** + * The HTTP status at the head of pi's failure text, or `undefined`. + * + * @param message - pi's failure text. + */ +export const leadingStatusOf = (message: string): number | undefined => { + const status = LEADING_STATUS.exec(message)?.[1]; + return status === undefined ? undefined : Number(status); +}; + +/** + * A failure reclassified as the gateway's refusal, or `undefined` when its + * text names none. The copy keeps every field, the message unchanged; the + * status is the failure's own when it carries a number, else the text's head. + * + * @param failure - a failure record, as a stream's finish carries one. + */ +export const classifyGatewayRefusal = ( + failure: Readonly> +): Record | undefined => { + const { message } = failure; + if (typeof message !== "string") { + return undefined; + } + const type = refusalTypeOf(message); + const code = type === undefined ? undefined : GATEWAY_REFUSAL_CODES[type]; + if (code === undefined) { + return undefined; + } + const status = + typeof failure.status === "number" + ? failure.status + : leadingStatusOf(message); + return { ...failure, code, ...(status === undefined ? {} : { status }) }; +}; + +/** + * A stream chunk with its refusal reclassified: a copy when it is an error + * finish whose failure classifies (chunks may be frozen), else the same + * object. + * + * @param chunk - one chunk of a host stream. + */ +export const reclassifyFinish = (chunk: unknown): unknown => { + if (!isRecord(chunk) || chunk.type !== "finish") { + return chunk; + } + const { reason } = chunk; + if ( + !isRecord(reason) || + reason.kind !== "error" || + !isRecord(reason.failure) + ) { + return chunk; + } + const failure = classifyGatewayRefusal(reason.failure); + return failure === undefined + ? chunk + : { ...chunk, reason: { ...reason, failure } }; +}; + +/** + * A stream with its refusal reclassified, every other chunk passed through as + * the same object. A consumer that stops early closes the source. + * + * @param chunks - a host stream. + * @yields {unknown} each chunk, a refusal's finish as a reclassified copy. + */ +export const reclassifyRefusals = async function* reclassifyRefusals( + chunks: AsyncIterable +): AsyncGenerator { + // oxlint-disable-next-line no-await-in-loop -- a stream is read in order, one chunk at a time. + for await (const chunk of chunks) { + yield reclassifyFinish(chunk); + } +}; diff --git a/test/e2e/keyed-probes.e2e.ts b/test/e2e/keyed-probes.e2e.ts index 95c15e2f..fd982809 100644 --- a/test/e2e/keyed-probes.e2e.ts +++ b/test/e2e/keyed-probes.e2e.ts @@ -56,10 +56,19 @@ * completions cell (`keyedVariantCells`, lessons L4, L5 and L10): the probe * identity beside a minted `x-opencode-request`, `x-opencode-project` as a * folder name and as a 40-hex digest, and a paid completions model that - * reasons, with no `reasoning_effort` and with `"none"`. Every 429 either case - * meets has its `Retry-After` noted (lesson L9). All of it is recorded — - * the variants table and the `Retry-After` line follow the keyed table in - * `keyed.md` — and none of it is asserted: P6 and P-R3 act on what it shows. + * reasons, with no `reasoning_effort` and with `"none"`. It also sends the + * console study's rows: a raw (non-`ses_`) session id (OC9), the + * session-only header set (OC8) and the harness's User-Agent (OC6), and three + * rows on a free completions model sent with NO key — the probe identity, a + * raw session id, the harness's User-Agent — which are the only keyless + * requests inside the keyed tier. Every keyed 429 either case meets has its + * `Retry-After` noted (lesson L9); a keyless free row's 429 is not a keyed + * one, and the free tier's usage cap is not a rate limit (L3), so neither is + * noted. All of it is recorded — the variants table and the `Retry-After` + * line follow the keyed table in `keyed.md` — and none of it is asserted: the + * lessons' rows feed P6 (L4, L5) and the L10 follow-up, and the console + * study's rows an owner's OC6 decision, the session derivation's OC9 and P6's + * OC8. * * Armed by the `probes` tier (`OPENCODE_E2E_KEYED`, unset meaning `full`): * CI runs it on pull requests that touch `src/host/**`, the modules that @@ -104,6 +113,7 @@ import { } from "../support/endpoint-record.ts"; import { excerpt, + freeCompletionsModel, isArmed, KEYED_VARIANTS, keyedCell, @@ -213,10 +223,10 @@ describe.skipIf( }; const recorded: RecordedProbe[] = []; const variants: RecordedVariant[] = []; - /** Every keyed 429 and its `Retry-After` (lesson L9), matrix and variants alike. */ + /** Every keyed rate-limit 429 and its `Retry-After` (lesson L9), matrix and variants alike. */ const retryAfters: { cell: string; value: string | null }[] = []; - /** {@link sendWithRetry}, noting the `Retry-After` of every 429 it met. */ + /** {@link sendWithRetry}, noting the `Retry-After` of every rate-limit 429 it met (`retryAfterOf` leaves out the usage cap). */ const sendNoting = async ( cell: MatrixCell, name: string @@ -343,7 +353,7 @@ describe.skipIf( ); it.for(["opencode", "opencode-go"] as const)( - "%s: records the header and reasoning variants on the default completions cell (L4, L5, L10)", + "%s: records the header, reasoning and console-study variants on the default completions cell (L4, L5, L10, OC6, OC8, OC9)", { timeout: KEYED_VARIANTS.length * 2 * (PROBE_TIMEOUT_MS + RETRY_AFTER_MS) + @@ -363,11 +373,17 @@ describe.skipIf( )?.model; const variantCells = keyedVariantCells( plane, - { reasoning: reasoningCompletionsModel(slices[plane]), representative }, + { + free: freeCompletionsModel(slices[plane]), + reasoning: reasoningCompletionsModel(slices[plane]), + representative, + }, key ); - // Recorded, never asserted: the variants measure what P6 and P-R3 - // decide, and no answer here says anything about the default family. + // Recorded, never asserted: the lessons' rows feed P6 (L4, L5) and the + // L10 follow-up, the console study's rows an owner's OC6 decision, the + // session derivation's OC9 and P6's OC8, and no answer here says + // anything about the default family. const rows: KeyedRow[] = []; for (const cell of variantCells) { const name = `${plane} ${cell.variant.id}`; @@ -378,7 +394,12 @@ describe.skipIf( verdict: "no-model", }; if (cell.body !== undefined) { - const attempts = await sendNoting(cell, name); + // A keyless free row's 429 is not a keyed one: it stays off the + // `Retry-After` line (L9), which is about keyed 429s. + const attempts = + cell.variant.keyed === false + ? await sendWithRetry(cell) + : await sendNoting(cell, name); const answer = attempts.at(-1); const verdict = keyedVerdictOf(attempts); row = { diff --git a/test/endpoint-record.test.ts b/test/endpoint-record.test.ts index 4eb06e8f..12ca38d3 100644 --- a/test/endpoint-record.test.ts +++ b/test/endpoint-record.test.ts @@ -215,7 +215,7 @@ describe("endpoint record · the table", () => { }); }); -describe("endpoint record · the records beside the tables (L4, L5, L9, L10, L13)", () => { +describe("endpoint record · the records beside the tables (L4, L5, L9, L10, L13, OC6, OC8, OC9)", () => { it("prints the System One family on one line, a shape it did not reach as not sent", () => { expect( systemOneLine([ @@ -276,9 +276,19 @@ describe("endpoint record · the records beside the tables (L4, L5, L9, L10, L13 expect(lines[6]).toBe( "| `opencode` | `x-opencode-project: ` | L5 | — | not sent |" ); - expect(lines.at(-1)).toBe( + // `reasoning-none` is no longer the last variant: the console study's + // rows follow it. + expect( + lines.find((line) => + line.startsWith('| `opencode-go` | `reasoning_effort: "none"` |') + ) + ).toBe( '| `opencode-go` | `reasoning_effort: "none"` | L10 | — | no-model |' ); + // A keyless row says so in its label. + expect( + lines.find((line) => line.startsWith("| `opencode` | a free model")) + ).toContain("no key"); expect( VARIANT_ROWS.map(([plane, variant]) => `${plane} ${variant.id}`) ).toEqual( @@ -290,7 +300,7 @@ describe("endpoint record · the records beside the tables (L4, L5, L9, L10, L13 it("prints every keyed 429's Retry-After, absent included, with nothing that could break the line", () => { expect(retryAfterLine([])).toBe( - "`Retry-After` on a keyed 429: no 429 was answered." + "`Retry-After` on a keyed 429: no keyed 429 was answered." ); expect( retryAfterLine([ diff --git a/test/gateway-refusal.test.ts b/test/gateway-refusal.test.ts new file mode 100644 index 00000000..a94ff404 --- /dev/null +++ b/test/gateway-refusal.test.ts @@ -0,0 +1,337 @@ +/** + * The gateway's refusals, read off the failure text pi hands the host + * (`src/utils/gateway-refusal.ts`, L2). + * + * Pure: a failure record in, a reclassified copy or `undefined` out. The text + * table is pi 1.5.2's, measured over loopback for each protocol: completions + * opens `403: `, Responses ` API error (403): `, Anthropic `403 `, + * and Google carries the body alone, with no status (a pi gap the module does + * not fill, D26). + * + * @module test/gateway-refusal.test + */ +import { describe, expect, it } from "vitest"; + +import { + classifyGatewayRefusal, + FREE_TIER_CODE, + GATEWAY_REFUSAL_CODES, + leadingStatusOf, + MODEL_UNAVAILABLE_CODE, + reclassifyFinish, + reclassifyRefusals, + refusalTypeOf, +} from "./support/src.ts"; + +/** One refusal: its type, its status and the gateway's own body. */ +interface Refusal { + readonly type: string; + readonly status: number; + readonly body: string; + readonly code: string; +} + +/** The five refusal types, with the bodies and statuses the gateway sends. */ +const REFUSALS: readonly Refusal[] = [ + { + body: JSON.stringify({ + error: { + message: "OpenCode's free tier can only be used from within OpenCode", + type: "FreeTierError", + }, + }), + code: FREE_TIER_CODE, + status: 403, + type: "FreeTierError", + }, + { + body: JSON.stringify({ + error: { + message: "Free usage limit reached. Free models work in OpenCode", + type: "FreeUsageLimitError", + }, + }), + code: FREE_TIER_CODE, + status: 429, + type: "FreeUsageLimitError", + }, + { + body: JSON.stringify({ + error: { + message: "This model is not available in your country.", + type: "RegionError", + }, + }), + code: MODEL_UNAVAILABLE_CODE, + status: 403, + type: "RegionError", + }, + { + body: JSON.stringify({ + error: { + message: "Model mimo-v2.5-pro has been deprecated.", + type: "ModelDeprecated", + }, + type: "error", + }), + code: MODEL_UNAVAILABLE_CODE, + status: 410, + type: "ModelDeprecated", + }, + // `ModelDeprecated` is measured at 410 (`docs/upstream-findings.md`) and at + // 400 (`docs/redirect-planes.md` §13); the probes hold it as gone at 404 + // too (`isModelDeprecated`), and the 404 fixture guards that rule: the type + // decides the code, and the status is kept whichever it is. + { + body: JSON.stringify({ + error: { + message: "Model mimo-v2.5-pro has been deprecated.", + type: "ModelDeprecated", + }, + }), + code: MODEL_UNAVAILABLE_CODE, + status: 400, + type: "ModelDeprecated", + }, + { + body: JSON.stringify({ + error: { + message: "Model mimo-v2.5-pro has been deprecated.", + type: "ModelDeprecated", + }, + }), + code: MODEL_UNAVAILABLE_CODE, + status: 404, + type: "ModelDeprecated", + }, + { + body: JSON.stringify({ + error: { + message: "ModelProtocolUnsupported: m is not served on this protocol", + type: "ModelProtocolUnsupported", + }, + }), + code: MODEL_UNAVAILABLE_CODE, + status: 400, + type: "ModelProtocolUnsupported", + }, +]; + +/** pi 1.5.2's failure text per protocol, around the gateway's body. */ +const FORMATS: readonly { + readonly protocol: string; + readonly text: (status: number, body: string) => string; + readonly carriesStatus: boolean; +}[] = [ + { + carriesStatus: true, + protocol: "openai-completions", + text: (status, body) => `${String(status)}: ${body}`, + }, + { + carriesStatus: true, + protocol: "openai-responses", + text: (status, body) => `opencode API error (${String(status)}): ${body}`, + }, + { + carriesStatus: true, + protocol: "anthropic-messages", + text: (status, body) => `${String(status)} ${body}`, + }, + { + carriesStatus: false, + protocol: "google-generative-ai", + text: (_status, body) => body, + }, +]; + +const CASES = REFUSALS.flatMap((refusal) => + FORMATS.map( + (format) => + [ + `${refusal.type} ${String(refusal.status)}`, + format.protocol, + refusal, + format, + ] as const + ) +); + +describe("gateway-refusal · the five refusal types", () => { + it.each(CASES)( + "classifies %s in %s's text, keeping pi's text and the status at its head", + (_type, _protocol, refusal, format) => { + const message = format.text(refusal.status, refusal.body); + expect(refusalTypeOf(message)).toBe(refusal.type); + const classified = classifyGatewayRefusal({ code: "AUTH", message }); + expect(classified).toEqual( + format.carriesStatus + ? { code: refusal.code, message, status: refusal.status } + : { code: refusal.code, message } + ); + expect(classified?.message).toBe(message); + } + ); + + it("covers every type its code table names", () => { + // The table and the cases above agree: no type is mapped but untested. + expect(Object.keys(GATEWAY_REFUSAL_CODES).toSorted()).toEqual( + [...new Set(REFUSALS.map((refusal) => refusal.type))].toSorted() + ); + }); +}); + +describe("gateway-refusal · what it leaves alone", () => { + it("leaves every failure that names no refusal type alone", () => { + for (const message of [ + '401: {"message":"Invalid API key","type":"AuthError"}', + '429: {"message":"Too many requests","type":"rate_limit_error"}', + '403: {"message":"Upstream request failed: Model access is disabled","type":"server_error"}', + "400 This model's maximum context length is 128000 tokens.", + // The gateway's untyped "Model is unavailable" (the probes read it as + // model-gone) is outside L2's five types: the bridge's reading stays. + '400: {"error":{"message":"Model is unavailable"}}', + // A type token inside a longer word is not the type. + "403: NotAFreeTierErrorAtAll", + ]) { + expect(classifyGatewayRefusal({ code: "AUTH", message })).toBeUndefined(); + } + expect(classifyGatewayRefusal({ code: "AUTH" })).toBeUndefined(); + expect( + classifyGatewayRefusal({ code: "AUTH", message: 403 }) + ).toBeUndefined(); + expect(refusalTypeOf("nothing here")).toBeUndefined(); + }); + + it("reads a status only at the text's head", () => { + expect(leadingStatusOf('403: {"type":"FreeTierError"}')).toBe(403); + expect(leadingStatusOf("opencode-go API error (429): {}")).toBe(429); + expect(leadingStatusOf('410 {"type":"error"}')).toBe(410); + expect(leadingStatusOf('{"error":{"code":403}}')).toBeUndefined(); + expect(leadingStatusOf("upstream answered 403")).toBeUndefined(); + // Outside an HTTP status's range, or not a whole token. + expect(leadingStatusOf("600: x")).toBeUndefined(); + expect(leadingStatusOf("4031: x")).toBeUndefined(); + expect(leadingStatusOf("403")).toBeUndefined(); + }); + + it("keeps a failure's own numeric status over the text's", () => { + const message = '403: {"type":"FreeTierError"}'; + expect( + classifyGatewayRefusal({ code: "AUTH", message, status: 401 }) + ).toEqual({ code: FREE_TIER_CODE, message, status: 401 }); + // A status that is not a number is not one: the text's is read. + expect( + classifyGatewayRefusal({ code: "AUTH", message, status: "401" }) + ).toEqual({ code: FREE_TIER_CODE, message, status: 403 }); + }); +}); + +/** A deep-frozen copy, as a host may hand its chunks over. */ +const deepFreeze = (value: T): T => { + if (typeof value === "object" && value !== null) { + for (const member of Object.values(value)) { + deepFreeze(member); + } + Object.freeze(value); + } + return value; +}; + +describe("gateway-refusal · a stream's finish", () => { + const message = + '403: {"message":"OpenCode\'s free tier can only be used from within OpenCode","type":"FreeTierError"}'; + + it("returns the same object for every chunk it does not reclassify", () => { + const chunks: unknown[] = [ + undefined, + "text", + { text: "hi", type: "text-delta" }, + { reason: { kind: "aborted" }, type: "finish" }, + { reason: { kind: "error" }, type: "finish" }, + { reason: "error", type: "finish" }, + { + reason: { failure: { code: "AUTH", message }, kind: "stop" }, + type: "finish", + }, + { + reason: { + failure: { code: "AUTH", message: "401: Invalid API key" }, + kind: "error", + }, + type: "finish", + }, + ]; + for (const chunk of chunks) { + expect(reclassifyFinish(chunk)).toBe(chunk); + } + }); + + it("copies a frozen finish whose failure classifies, and leaves the input as it was", () => { + const chunk = deepFreeze({ + reason: { + failure: { code: "AUTH", message, retryable: false }, + kind: "error", + }, + type: "finish", + usage: { input: 1 }, + }); + const out = reclassifyFinish(chunk); + expect(out).not.toBe(chunk); + expect(out).toEqual({ + reason: { + failure: { + code: FREE_TIER_CODE, + message, + retryable: false, + status: 403, + }, + kind: "error", + }, + type: "finish", + usage: { input: 1 }, + }); + expect(chunk.reason.failure.code).toBe("AUTH"); + }); + + it("yields every chunk in order, reclassifying the finish", async () => { + const delta = { text: "hi", type: "text-delta" }; + async function* source(): AsyncGenerator { + yield delta; + yield { + reason: { failure: { code: "AUTH", message }, kind: "error" }, + type: "finish", + }; + } + const out: unknown[] = []; + for await (const chunk of reclassifyRefusals(source())) { + out.push(chunk); + } + expect(out[0]).toBe(delta); + expect(out[1]).toEqual({ + reason: { + failure: { code: FREE_TIER_CODE, message, status: 403 }, + kind: "error", + }, + type: "finish", + }); + }); + + it("closes the source when its consumer stops early", async () => { + let closed = false; + async function* source(): AsyncGenerator { + try { + yield { type: "text-delta" }; + yield { type: "text-delta" }; + } finally { + closed = true; + } + } + // What a `for await` that breaks after the first chunk does. + const iterator = reclassifyRefusals(source())[Symbol.asyncIterator](); + const first = await iterator.next(); + expect(first.value).toEqual({ type: "text-delta" }); + await iterator.return?.(undefined); + expect(closed).toBe(true); + }); +}); diff --git a/test/keyed-probes.test.ts b/test/keyed-probes.test.ts index 7af4a829..c8a57271 100644 --- a/test/keyed-probes.test.ts +++ b/test/keyed-probes.test.ts @@ -19,6 +19,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { readdirSync, readFileSync } from "node:fs"; +import { attributionHeaders } from "@deepseek-ai/dsh-llm"; import { afterEach, describe, expect, it } from "vitest"; import { handleGatewayRequest } from "./mock/opencode-gateway.ts"; @@ -29,6 +30,9 @@ import { credentialHeaders, excerpt, FLIP_EVIDENCE, + freeCompletionsModel, + freeTurnBody, + harnessUserAgent, isArmed, KEYED_VARIANTS, keyedBody, @@ -40,6 +44,7 @@ import { pluginIdentityHeaders, PROBE_IDENTITY, PROBE_PROJECTS, + PROBE_RAW_SESSION_ID, PROBE_REQUEST_ID, PROBE_SESSION_ID, reasoningCompletionsModel, @@ -60,6 +65,7 @@ import { CONFIG_DEFAULTS, isOpenCodeMessageId, isOpenCodeSessionId, + maybeInjectCoreTools, newOpenCodeMessageId, OPENCODE_GO_CATALOG, OPENCODE_PATCH_USER_AGENT, @@ -543,6 +549,13 @@ describe("keyed probes · reading an answer", () => { [401, "Insufficient account funds", "account"], [402, "", "account"], [429, "", "rate-limited"], + // The free tier's usage cap is the gate's decision, not a rate limit + // (L3): a paid keyed cell never meets it, a keyless free row can. + [ + 429, + '{"type":"error","error":{"type":"FreeUsageLimitError","message":"Free usage limit reached"}}', + "free-usage-limited", + ], [500, "", "other"], [502, "", "other"], [500, '{"error":{"message":"internal error"}}', "other"], @@ -682,6 +695,16 @@ describe("keyed probes · what a plane decides", () => { plane(["rate-limited", "rate-limited", "rate-limited", "rate-limited"]), "skip", ], + [ + "default cells all free-usage-limited", + plane([ + "free-usage-limited", + "free-usage-limited", + "free-usage-limited", + "free-usage-limited", + ]), + "skip", + ], [ "default cells all upstream-busy", plane([ @@ -1552,7 +1575,7 @@ describe("the e2e-host job · its pull-request scope", () => { }); }); -describe("keyed probes · the variants on the default completions cell (L4, L5, L9, L10)", () => { +describe("keyed probes · the variants on the default completions cell (L4, L5, L9, L10, OC6, OC8, OC9)", () => { const models = { reasoning: "zen-think", representative: "zen-chat" }; it("each variant is the default completions cell, keyed, changed by its own header or body field and nothing else", () => { @@ -1564,6 +1587,12 @@ describe("keyed probes · the variants on the default completions cell (L4, L5, "project-digest", "reasoning-default", "reasoning-none", + "session-raw", + "session-only", + "ua-harness", + "free-baseline", + "session-raw-free", + "ua-harness-free", ]); const [baseline, request, folder, digest, reasoning, none] = cells; // The baseline is exactly the matrix's keyed modern completions cell. @@ -1637,7 +1666,13 @@ describe("keyed probes · the variants on the default completions cell (L4, L5, cells .filter((cell) => cell.body === undefined) .map((cell) => cell.variant.id) - ).toEqual(["reasoning-default", "reasoning-none"]); + ).toEqual([ + "reasoning-default", + "reasoning-none", + "free-baseline", + "session-raw-free", + "ua-harness-free", + ]); expect(cells[0]?.url).toBe( "https://opencode.ai/inference/go/openai/v1/chat/completions" ); @@ -1679,7 +1714,215 @@ describe("keyed probes · the variants on the default completions cell (L4, L5, "L5", "L10", "L10", + "OC9", + "OC8", + "OC6", + "OC6, OC9", + "OC9", + "OC6", + ]); + for (const variant of KEYED_VARIANTS) { + expect(variant.label, variant.id).not.toContain("|"); + } + }); + + /** The cells by variant id. */ + const byId = ( + cells: readonly C[] + ): Record => + Object.fromEntries(cells.map((cell) => [cell.variant.id, cell])); + + /** A header bag without the named headers. */ + const omitting = ( + headers: Readonly> | undefined, + ...names: string[] + ): Record => + Object.fromEntries( + Object.entries(headers ?? {}).filter(([name]) => !names.includes(name)) + ); + + it("the console study's rows (OC6, OC8, OC9) each differ from their baseline in exactly what they measure, and none carries the vendor's marker", () => { + const cells = keyedVariantCells( + "opencode", + { free: "zen-free", ...models }, + "sk-probe" + ); + const row = byId(cells); + const { baseline } = row; + const raw = row["session-raw"]; + const only = row["session-only"]; + const ua = row["ua-harness"]; + // The host's own conversation id: `session-` and a v4 UUID + // (dsh-api-session-controller's `createMinted`), never a `ses_` id. + expect(PROBE_RAW_SESSION_ID).toMatch( + /^session-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u + ); + expect(isOpenCodeSessionId(PROBE_RAW_SESSION_ID)).toBe(false); + // Its tail sits outside the band the gateway admits for a `ses_` id + // (0xFC0..0xFFE), as a random id's almost always does, so a pass cannot + // come from a crafted in-band tail. + expect(Number.parseInt(PROBE_RAW_SESSION_ID.slice(-3), 16)).toBeLessThan( + 0xf_c0 + ); + const sessions = ["x-opencode-session", "x-opencode-session-id"]; + expect(omitting(raw?.headers, ...sessions)).toEqual( + omitting(baseline?.headers, ...sessions) + ); + for (const name of sessions) { + expect(raw?.headers[name], name).toBe(PROBE_RAW_SESSION_ID); + } + expect(Object.keys(only?.headers ?? {}).toSorted()).toEqual([ + "authorization", + "content-type", + "user-agent", + "x-opencode-session", ]); + // Names and values: the baseline's headers, less the three it omits. + expect(only?.headers).toEqual( + omitting( + baseline?.headers, + "x-opencode-client", + "x-opencode-project", + "x-opencode-session-id" + ) + ); + expect(omitting(ua?.headers, "user-agent")).toEqual( + omitting(baseline?.headers, "user-agent") + ); + expect(ua?.headers["user-agent"]).toBe(attributionHeaders()["user-agent"]); + expect(harnessUserAgent()).toMatch(/^deepseek-harness\//u); + expect(harnessUserAgent()).not.toBe(OPENCODE_UA); + for (const cell of [raw, only, ua]) { + expect(cell?.body).toEqual(baseline?.body); + } + for (const cell of cells) { + expect(cell.headers, cell.variant.id).not.toHaveProperty( + "x-opencode-plugin" + ); + } + }); + + it("the free rows send what the plugin's free turn sends with no key, and the offline gateway serves the free baseline", () => { + const cells = keyedVariantCells( + "opencode", + { free: "zen-free", ...models }, + "sk-probe" + ); + const row = byId(cells); + const free = row["free-baseline"]; + for (const name of CREDENTIAL_HEADERS) { + expect(free?.headers, name).not.toHaveProperty(name); + } + expect(omitting(free?.headers, "content-type")).toEqual(PROBE_IDENTITY); + // The body is what the plugin's own injector makes of a toolless turn. + const injected = maybeInjectCoreTools( + "https://opencode.ai/inference/openai/v1/chat/completions", + JSON.stringify({ + ...keyedBody("openai-completions", "zen-free"), + stream: true, + }), + new Headers(), + { enabled: true, modelMarker: "*" } + ); + if (typeof injected !== "string") { + throw new TypeError("the injector returned no rewritten body"); + } + expect(free?.body).toEqual(JSON.parse(injected)); + expect(free?.body).toEqual(freeTurnBody("zen-free")); + expect(free?.body).toMatchObject({ stream: true, tool_choice: "none" }); + const raw = row["session-raw-free"]; + const ua = row["ua-harness-free"]; + const sessions = ["x-opencode-session", "x-opencode-session-id"]; + expect(omitting(raw?.headers, ...sessions)).toEqual( + omitting(free?.headers, ...sessions) + ); + expect(raw?.headers["x-opencode-session-id"]).toBe(PROBE_RAW_SESSION_ID); + expect(omitting(ua?.headers, "user-agent")).toEqual( + omitting(free?.headers, "user-agent") + ); + expect(ua?.headers["user-agent"]).toBe(harnessUserAgent()); + for (const cell of [raw, ua]) { + expect(cell?.body).toEqual(free?.body); + } + + // The free baseline on the shipped catalog's own free model: served. + const model = freeCompletionsModel(OPENCODE_ZEN_CATALOG); + if (model === undefined) { + throw new Error("the bundled Zen catalog has no free completions model"); + } + const shipped = byId( + keyedVariantCells( + "opencode", + { free: model, ...models }, + "sk-probe-0123456789abcdef" + ) + )["free-baseline"]; + if (shipped === undefined) { + throw new Error("no free baseline"); + } + const url = new URL(shipped.url); + const { reply } = handleGatewayRequest({ + bodyText: JSON.stringify(shipped.body), + headers: shipped.headers, + method: shipped.method, + url: `${url.pathname}${url.search}`, + }); + expect( + keyedVerdictOf([{ bodyText: reply.body, status: reply.status }]) + ).toBe("served"); + }); + + it("a keyless free row's answers read as a keyed cell's, known answers included (§13 says how)", () => { + // The free tier refused the session: a `FreeTierError` on a keyless row. + expect( + keyedVerdictOf([ + { + bodyText: + '{"error":{"message":"OpenCode\'s free tier can only be used from within OpenCode","type":"FreeTierError"}}', + status: 403, + }, + ]) + ).toBe("session-refused"); + // L8's keyless 401 for an id the gateway reads as malformed: no key was + // sent, so §13 reads this row's `auth-refused` as the key gate answering, + // not as a refused key. + expect( + keyedVerdictOf([ + { + bodyText: + '{"error":{"message":"Invalid API key","type":"AuthError"}}', + status: 401, + }, + ]) + ).toBe("auth-refused"); + // The free tier's usage cap, the answer run 43542273415's free probe got: + // the gate refusing the caller, never retried and never a rate limit + // (L3), so the row measured nothing about OC6, OC9 or L7. + expect( + keyedVerdictOf([ + { + bodyText: + '{"type":"error","error":{"type":"FreeUsageLimitError","message":"Free usage limit reached"}}', + retryAfter: "3600", + status: 429, + }, + ]) + ).toBe("free-usage-limited"); + }); + + it("the free model is the first free, current completions model", () => { + expect( + freeCompletionsModel( + [ + spec("paid"), + { ...spec("resp-free", "@ai-sdk/openai"), is_free: true }, + { ...spec("retired-free"), is_free: true }, + { ...spec("chat-free"), is_free: true }, + ], + (id) => id === "retired-free" + ) + ).toBe("chat-free"); + expect(freeCompletionsModel([spec("paid")])).toBeUndefined(); }); it("the offline gateway serves the baseline with the plugin's identity", () => { @@ -1712,13 +1955,25 @@ describe("keyed probes · the variants on the default completions cell (L4, L5, ).toBe("served"); }); - it("reads every 429's Retry-After, and names one that carried none (L9)", () => { + it("reads every rate-limit 429's Retry-After, leaves out the usage cap, and names one that carried none (L9)", () => { expect( retryAfterOf([ { bodyText: "", retryAfter: "30", status: 429 }, { bodyText: "", status: 429 }, ]) ).toEqual(["30", null]); + // The free tier's usage cap is not a rate limit (L3): it finishes a turn + // as FREE_TIER, which nothing retries, so its delay is not L9's question. + expect( + retryAfterOf([ + { + bodyText: '{"error":{"type":"FreeUsageLimitError"}}', + retryAfter: "3600", + status: 429, + }, + { bodyText: "", retryAfter: "5", status: 429 }, + ]) + ).toEqual(["5"]); expect( retryAfterOf([ undefined, diff --git a/test/mock/opencode-gateway.ts b/test/mock/opencode-gateway.ts index 3aabf919..961b0eba 100644 --- a/test/mock/opencode-gateway.ts +++ b/test/mock/opencode-gateway.ts @@ -239,7 +239,8 @@ const fixtureReply = ( status: 200, }); -const errorReply = ( +/** A refusal in the gateway's `{ error: { message, type } }` shape. */ +export const errorReply = ( status: number, message: string, type: string @@ -350,7 +351,8 @@ const matchRoute = (path: string): RouteMatch | undefined => { const SESSION_BAND_MIN = 0xfc0; const SESSION_BAND_MAX = 0xffe; -const FREE_TIER_MESSAGE = +/** The gateway's own words in a `FreeTierError`. */ +export const FREE_TIER_MESSAGE = "OpenCode's free tier can only be used from within OpenCode. Download it at https://opencode.ai and keep building."; /** The low twelve bits of a session id, or `undefined` when it is not one. */ diff --git a/test/mock/plane-refusals.mock.ts b/test/mock/plane-refusals.mock.ts new file mode 100644 index 00000000..7547934f --- /dev/null +++ b/test/mock/plane-refusals.mock.ts @@ -0,0 +1,335 @@ +/** + * The gateway's refusals, as a plane turn finishes them (L2). + * + * The gateway refuses a turn it will not serve with a typed body: + * `FreeTierError` (403) and `FreeUsageLimitError` (429) for the free tier, + * `RegionError` (403), `ModelDeprecated` (410) and `ModelProtocolUnsupported` + * (400) for the model. On pi-ai's own route the host's classifier reads those + * by status and words, as AUTH, QUOTA, PI_AI_ERROR or INVALID_REQUEST, and the + * conversation replaces an AUTH or QUOTA failure's message with its own + * ("API key is invalid", "Request quota exhausted."), so the gateway's words + * never reach the person (`test/mock/refusal-classification.mock.ts` pins that + * route). On the planes the plugin serves, the turn finishes with the + * refusal's own code, `FREE_TIER` or `MODEL_UNAVAILABLE`, and the gateway's + * words and status (none on Google, whose text pi leaves without one; + * `src/utils/gateway-refusal.ts`). + * + * The host is tier A with the plugin mounted, as in `plane-dispatch.mock.ts`: + * each turn goes through the real `LlmRuntime`, the `PlaneAdapter` and pi's + * protocol SDK to the mock gateway, which answers the case's refusal once its + * own checks (credential, session band, protocol) have passed. The models are + * the catalog's own (`fixtureModel`), one per protocol. + * + * @module test/mock/plane-refusals.mock + */ + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + adaptersOf, + fakeCredentials, + type HostLlm, + mountTierA, + type TierA, + waitForPlaneOwner, +} from "../support/host.ts"; +import { + clearCapturedApiKeys, + FREE_TIER_CODE, + MODEL_UNAVAILABLE_CODE, +} from "../support/src.ts"; +import { urlOf } from "../test-helpers.ts"; +import { startMockGateway, type MockGateway } from "./gateway-server.ts"; +import { fixtureModel, GO_KEY, ZEN_KEY } from "./helpers.ts"; +import { + errorReply, + FREE_TIER_MESSAGE, + type MockReply, +} from "./opencode-gateway.ts"; + +type Plane = "opencode" | "opencode-go"; + +/** One refusal the gateway sends, with the code a plane turn finishes with. */ +interface Refusal { + readonly type: string; + readonly status: number; + readonly reply: MockReply; + /** The gateway's own words, which the finish must keep. */ + readonly words: string; + readonly code: string; +} + +const FREE_USAGE_MESSAGE = + "Free usage limit reached. Free models work in OpenCode"; +const REGION_MESSAGE = "This model is not available in your country."; + +/** The five refusals, each in the shape the gateway sends it. */ +const REFUSALS: readonly Refusal[] = [ + { + code: FREE_TIER_CODE, + reply: errorReply(403, FREE_TIER_MESSAGE, "FreeTierError"), + status: 403, + type: "FreeTierError", + words: "can only be used from within OpenCode", + }, + { + code: FREE_TIER_CODE, + reply: errorReply(429, FREE_USAGE_MESSAGE, "FreeUsageLimitError"), + status: 429, + type: "FreeUsageLimitError", + words: "Free usage limit reached", + }, + { + code: MODEL_UNAVAILABLE_CODE, + reply: errorReply(403, REGION_MESSAGE, "RegionError"), + status: 403, + type: "RegionError", + words: "not available in your country", + }, + { + code: MODEL_UNAVAILABLE_CODE, + // The Anthropic-shaped envelope the gateway answers a retired id with + // (`docs/upstream-findings.md`). + reply: { + body: JSON.stringify({ + error: { + message: "Model retired-model has been deprecated.", + type: "ModelDeprecated", + }, + type: "error", + }), + contentType: "application/json", + status: 410, + }, + status: 410, + type: "ModelDeprecated", + words: "has been deprecated", + }, + { + code: MODEL_UNAVAILABLE_CODE, + reply: errorReply( + 400, + "ModelProtocolUnsupported: this model is not served on this endpoint", + "model_protocol_unsupported" + ), + status: 400, + type: "ModelProtocolUnsupported", + words: "is not served on this endpoint", + }, +]; + +/** One model per protocol on Zen, and Go's completions. */ +const SUBJECTS: readonly { + readonly plane: Plane; + readonly protocol: string; + readonly model: string; +}[] = [ + ...[ + "openai-completions", + "openai-responses", + "anthropic-messages", + "google-generative-ai", + ].map((protocol) => ({ + model: fixtureModel("opencode", protocol, "paid").id, + plane: "opencode" as const, + protocol, + })), + { + model: fixtureModel("opencode-go", "openai-completions", "paid").id, + plane: "opencode-go", + protocol: "openai-completions", + }, +]; + +/** Each plane's profile: its key, and the subjects it declares. */ +const PROVIDERS = { + opencode: { + apiKeyEnv: "OPENCODE_API_KEY", + models: SUBJECTS.filter((subject) => subject.plane === "opencode").map( + (subject) => ({ id: subject.model }) + ), + }, + "opencode-go": { + apiKeyEnv: "OPENCODE_GO_API_KEY", + models: SUBJECTS.filter((subject) => subject.plane === "opencode-go").map( + (subject) => ({ id: subject.model }) + ), + }, +}; + +/** The reply every model request gets; `undefined` serves the fixture. */ +let refusal: MockReply | undefined; +let gateway: MockGateway; +let tier: TierA | undefined; +let llm: HostLlm; + +const realFetch = globalThis.fetch; + +beforeAll(async () => { + gateway = await startMockGateway({ + behavior: (request) => + request.kind === "completions" || + request.kind === "responses" || + request.kind === "messages" || + request.kind === "google" + ? refusal + : undefined, + }); + 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 + ); + } + return Promise.reject( + new TypeError(`plane-refusals: 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: PROVIDERS, + }); + await waitForPlaneOwner(tier.llm, ["opencode", "opencode-go"]); + ({ llm } = tier); +}); + +afterAll(async () => { + await tier?.dispose(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + await gateway.close(); +}); + +beforeEach(() => { + gateway.requests.length = 0; + refusal = undefined; + clearCapturedApiKeys(); +}); + +afterEach(() => { + refusal = undefined; +}); + +/** A finish's failure, as far as these cases read it. */ +interface Failure { + readonly code?: string; + readonly message?: string; + readonly status?: number; +} + +/** One turn through the host's stream path; answers its finish. */ +const finishOf = async ( + plane: Plane, + model: string +): Promise<{ kind?: string; failure?: Failure }> => { + let finish: { kind?: string; failure?: Failure } | undefined; + for await (const chunk of llm.stream({ + messages: [{ content: [{ text: "hi", type: "text" }], role: "user" }], + model, + provider: plane, + sessionId: "plane-refusals-session", + signal: AbortSignal.timeout(10_000), + } as never)) { + const typed = chunk as { + type: string; + reason?: { kind?: string; failure?: Failure }; + }; + if (typed.type === "finish") { + finish = typed.reason; + } + } + if (finish === undefined) { + throw new Error(`${plane}/${model}: the turn sent no finish`); + } + return finish; +}; + +/** The model requests the gateway saw. */ +const modelRequests = (): number => + gateway.requests.filter( + (request) => + request.kind !== "models" && + request.kind !== "usage" && + request.kind !== null + ).length; + +/** The codes the plane's adapter row retries. */ +const retryableOf = (plane: Plane): readonly string[] => + ( + adaptersOf(llm).get(plane)?.retryPolicy as + | { retryableCodes?: readonly string[] } + | undefined + )?.retryableCodes ?? []; + +const CASES = REFUSALS.flatMap((entry) => + SUBJECTS.map( + (subject) => + [ + entry.type, + `${subject.plane} ${subject.protocol}`, + entry, + subject, + ] as const + ) +); + +describe("plane refusals · the gateway's five refusal types", () => { + it.each(CASES)( + "%s on %s: the turn finishes as the refusal's own code with the gateway's words and status, after one dispatch, its code outside the plane's retryable codes", + async (_type, label, entry, subject) => { + refusal = entry.reply; + const finish = await finishOf(subject.plane, subject.model); + expect(finish.kind, label).toBe("error"); + expect(finish.failure?.code, label).toBe(entry.code); + expect(finish.failure?.message, label).toContain(entry.type); + expect(finish.failure?.message, label).toContain(entry.words); + // pi's Google text carries no status (a host gap, D26), so a Google + // refusal finishes with its code and words and no status. + expect(finish.failure?.status, label).toBe( + subject.protocol === "google-generative-ai" ? undefined : entry.status + ); + // One dispatch, no duplicate. This says nothing about retrying: that is + // dsh-llm-retry's, which tier A does not mount, and it resends only a + // code in the plane's `retryableCodes`, the assertion below. + expect(modelRequests(), label).toBe(1); + expect(gateway.requests.at(-1)?.status, label).toBe(entry.status); + expect(retryableOf(subject.plane), label).not.toContain(entry.code); + } + ); +}); + +describe("plane refusals · what keeps the host's own reading", () => { + const [completions] = SUBJECTS; + + it("a refusal outside the gateway's five types keeps the host's own reading", async () => { + if (completions === undefined) { + throw new Error("no completions subject"); + } + refusal = errorReply(401, "Invalid API key", "AuthError"); + const auth = await finishOf(completions.plane, completions.model); + expect(auth.kind).toBe("error"); + expect(auth.failure?.code).toBe("AUTH"); + refusal = errorReply(429, "Too many requests", "rate_limit_error"); + const limited = await finishOf(completions.plane, completions.model); + expect(limited.kind).toBe("error"); + expect(limited.failure?.code).toBe("RATE_LIMIT"); + expect(retryableOf(completions.plane)).toContain("RATE_LIMIT"); + }); +}); diff --git a/test/mock/refusal-classification.mock.ts b/test/mock/refusal-classification.mock.ts index 136ca03b..31a27e06 100644 --- a/test/mock/refusal-classification.mock.ts +++ b/test/mock/refusal-classification.mock.ts @@ -24,6 +24,12 @@ * one bundle (the `0.2.2-rc.2` bridge reads a leading HTTP status first, and * a pin on the older branch's source broke on it while the order held). * + * The AUTH reading this first case pins is pi-ai's OWN route's. On the planes + * the plugin serves, the gateway's typed refusals finish with their own codes + * instead (`FREE_TIER`, `MODEL_UNAVAILABLE`), because the conversation drops + * an AUTH failure's message for "API key is invalid"; + * `test/mock/plane-refusals.mock.ts` pins that. + * * It binds a loopback port and nothing else, like the sibling header suite, * so it runs in the mock suite on every push. * @@ -286,10 +292,10 @@ describe("refusal classification over a real socket", () => { expect(invalid.code).toBe("INVALID_REQUEST"); expect(invalid.message).toContain("not on the free tier"); // Order IS the contract: AUTH is asked first, so the same words at 403 - // are AUTH. That reading is accepted now — the pass-through means a - // free-tier refusal surfaces as AUTH carrying the gateway's own words, - // which is the honest answer to a request the gateway refused outside the - // credential. + // are AUTH on pi-ai's own route. The conversation shows an AUTH failure + // as "API key is invalid" and drops these words, which is why the planes + // finish the gateway's typed refusals with their own codes + // (`plane-refusals.mock.ts`). const refused = await classified(403); expect(refused.code).toBe("AUTH"); expect(refused.message).toContain("not on the free tier"); diff --git a/test/mock/request-shapes.mock.ts b/test/mock/request-shapes.mock.ts index 4b3cc457..1375f5df 100644 --- a/test/mock/request-shapes.mock.ts +++ b/test/mock/request-shapes.mock.ts @@ -35,12 +35,14 @@ import { SESSION_AFFINITY_HEADER, SESSION_HEADER, clearCapturedApiKeys, + DUMMY_READ_TOOL_FUNCTION, isOpenCodeSessionId, newOpenCodeMessageId, openCodeSessionIdFor, patchFetch, resolveConfig, } from "../support/src.ts"; +import { toolNamesOf } from "../test-helpers.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { firstSpecFor, @@ -288,6 +290,63 @@ describe("the request shape per configuration", () => { expect(names).toContain("bash"); }); + it("a free completions turn with no tools reaches the gateway with read, bash and tool_choice none", async () => { + const response = await als.run(sessionTurn(), () => + post( + patchedWith(resolveConfig()), + `${gateway.origin}/zen/v1/chat/completions`, + { messages: [{ content: "hi", role: "user" }], model: FREE } + ) + ); + expect(response.status).toBe(200); + const body = recorded().body as Record; + expect(body.tool_choice).toBe("none"); + expect(toolNamesOf(body)).toEqual(["read", "bash"]); + }); + + it("replaces an empty-schema read on a free completions turn instead of sending two", async () => { + const response = await als.run(sessionTurn(), () => + post( + patchedWith(resolveConfig()), + `${gateway.origin}/zen/v1/chat/completions`, + { + messages: [{ content: "hi", role: "user" }], + model: FREE, + tools: [ + { + function: { + name: "read", + parameters: { properties: {}, type: "object" }, + }, + type: "function", + }, + ], + } + ) + ); + expect(response.status).toBe(200); + const body = recorded().body as Record; + expect(toolNamesOf(body)).toEqual(["read", "bash"]); + // The shell is gone and the full schema stands in its place. + expect((body.tools as unknown[])[0]).toEqual(DUMMY_READ_TOOL_FUNCTION); + // The request brought a tool of its own, so no tool_choice is set. + expect("tool_choice" in body).toBe(false); + }); + + it("gives a Responses turn the tools and never a tool_choice", async () => { + const response = await als.run(sessionTurn(), () => + post( + patchedWith(resolveConfig({ freeModelMarker: "*" })), + responsesUrl(), + responsesBody(PAID) + ) + ); + expect(response.status).toBe(200); + const body = recorded().body as Record; + expect(toolNamesOf(body)).toEqual(["read", "bash"]); + expect("tool_choice" in body).toBe(false); + }); + it("sends no credential and warns when none resolves", async () => { vi.stubEnv("OPENCODE_API_KEY", ""); const warnings: unknown[][] = []; diff --git a/test/plane-provider.test.ts b/test/plane-provider.test.ts index 429a4e15..e5f505eb 100644 --- a/test/plane-provider.test.ts +++ b/test/plane-provider.test.ts @@ -281,6 +281,166 @@ describe("plane-provider · the adapter", () => { }); }); +describe("plane-provider · the gateway's refusals (L2)", () => { + const FREE_TIER = + '403: {"message":"OpenCode\'s free tier can only be used from within OpenCode","type":"FreeTierError"}'; + const DEPRECATED = + '410 {"type":"error","error":{"type":"ModelDeprecated","message":"Model m has been deprecated."}}'; + + /** A text delta, then a finish failing with `failure`. */ + const turnFailing = ( + failure: unknown, + delta: object, + state: { closed: boolean } + ) => + async function* gen(): AsyncGenerator { + try { + yield delta; + yield { reason: { failure, kind: "error" }, type: "finish" }; + } finally { + state.closed = true; + } + }; + + /** The plane adapter over a fake `PiAiAdapter` whose turns are `gen`'s. */ + const fakeBase = ( + gen: () => AsyncGenerator, + prepared?: unknown + ): { + prepareCall: (provider: string, model: string) => Promise; + stream: (options: unknown) => AsyncIterable; + } => + createPlaneAdapter({ + auth: {} as never, + effortNames: () => ({}), + modules: { + libraries: { root: "r", version: "v" }, + piAi: { + PiAiAdapter: class { + prepareCall(): Promise { + return Promise.resolve( + prepared ?? { model: {}, stream: () => gen() } + ); + } + + stream(): AsyncIterable { + return gen(); + } + }, + }, + } as unknown as HostModules, + profiles: () => new Map(), + resolveApiKey: async () => "k", + }) as never; + + const collect = async (chunks: unknown): Promise => { + const out: unknown[] = []; + for await (const chunk of chunks as AsyncIterable) { + out.push(chunk); + } + return out; + }; + + const failureOf = (chunk: unknown): unknown => + (chunk as { reason: { failure: unknown } }).reason.failure; + + it("a prepared call's stream finishes the gateway's FreeTierError as FREE_TIER with its status and words, and passes every other chunk through as the same object", async () => { + const delta = { text: "hi", type: "text-delta" }; + const state = { closed: false }; + const adapter = fakeBase( + turnFailing({ code: "AUTH", message: FREE_TIER }, delta, state) + ); + const prepared = (await adapter.prepareCall("opencode", "m")) as { + stream: (options: unknown) => unknown; + }; + const chunks = await collect(prepared.stream({})); + expect(chunks[0]).toBe(delta); + expect(failureOf(chunks[1])).toEqual({ + code: "FREE_TIER", + message: FREE_TIER, + status: 403, + }); + }); + + it("adapter.stream finishes the same way, and on both paths a consumer that stops early still closes the host's stream", async () => { + const state = { closed: false }; + const adapter = fakeBase( + turnFailing( + { code: "PI_AI_ERROR", message: DEPRECATED }, + { type: "text-delta" }, + state + ) + ); + const chunks = await collect(adapter.stream({})); + expect(failureOf(chunks[1])).toEqual({ + code: "MODEL_UNAVAILABLE", + message: DEPRECATED, + status: 410, + }); + expect(state.closed).toBe(true); + const early = { closed: false }; + const prepared = (await fakeBase( + turnFailing({ code: "AUTH", message: FREE_TIER }, {}, early) + ).prepareCall("opencode", "m")) as { + stream: (options: unknown) => AsyncIterable; + }; + // What a `for await` that breaks after the first chunk does. + const iterator = prepared.stream({})[Symbol.asyncIterator](); + const first = await iterator.next(); + expect(first.value).toEqual({}); + await iterator.return?.(undefined); + expect(early.closed).toBe(true); + // The same early stop on `adapter.stream` itself. + const direct = { closed: false }; + const directStream = fakeBase( + turnFailing({ code: "AUTH", message: FREE_TIER }, {}, direct) + ).stream({}); + const streamed = directStream[Symbol.asyncIterator](); + const head = await streamed.next(); + expect(head.value).toEqual({}); + expect(direct.closed).toBe(false); + await streamed.return?.(undefined); + expect(direct.closed).toBe(true); + }); + + it("keeps an aborted finish, an unmatched failure, a prepared call it cannot read and a stream that is not one as they were", async () => { + const aborted = { + reason: { + failure: { code: "ABORTED", message: FREE_TIER }, + kind: "aborted", + }, + type: "finish", + }; + const unmatched = { + reason: { + failure: { code: "AUTH", message: "401: Invalid API key" }, + kind: "error", + }, + type: "finish", + }; + async function* gen(): AsyncGenerator { + yield aborted; + yield unmatched; + } + const chunks = await collect(fakeBase(gen).stream({})); + expect(chunks[0]).toBe(aborted); + expect(chunks[1]).toBe(unmatched); + // A prepared call that is not a record, or carries no stream function. + expect(await fakeBase(gen, "odd").prepareCall("opencode", "m")).toBe("odd"); + expect( + await fakeBase(gen, { model: {}, stream: 5 }).prepareCall("opencode", "m") + ).toEqual({ model: {}, stream: 5 }); + // A stream function that answers no iterable answers what it answered. + const odd = (await fakeBase(gen, { + model: {}, + stream: () => "not a stream", + }).prepareCall("opencode", "m")) as { + stream: (options: unknown) => unknown; + }; + expect(odd.stream({})).toBe("not a stream"); + }); +}); + describe("plane-provider · planeAuth (pi-ai's auth.ts, restated)", () => { const recordKeyFor = (id: string): string => `llm-pi-ai/${id}`; const withService = (service: unknown) => ({ diff --git a/test/support/endpoint-record.ts b/test/support/endpoint-record.ts index 32efef0c..af67ecce 100644 --- a/test/support/endpoint-record.ts +++ b/test/support/endpoint-record.ts @@ -193,7 +193,9 @@ export const VARIANT_ROWS: readonly (readonly [MatrixPlane, KeyedVariant])[] = ( /** * The keyed variants' dated table: one row per plane and variant, the - * variant's model and what the default completions cell answered. + * variant's model and what the default completions cell answered. It holds + * the lessons' rows (L4, L5, L10) and the console study's (OC6, OC8, OC9), + * the free model's keyless rows among them. * * @param record.date - the UTC date of the run, `YYYY-MM-DD`. * @param record.run - the run's {@link runReference}. @@ -233,8 +235,10 @@ const headerValue = (value: string): string => value.replaceAll(/[^\w ,:.+-]/gu, "").slice(0, 40); /** - * The `Retry-After` line (lesson L9): every keyed 429 and the delay it named, - * or that none was answered. + * The `Retry-After` line (lesson L9): every keyed rate-limit 429 and the delay + * it named, or that none was answered. The keyless free rows and the free + * tier's usage cap never reach it (`retryAfterOf`, and the live suite's + * keyless branch), so the empty case says what it counted. * * @param entries - each 429's cell name and its `Retry-After`, `null` when * it carried none. @@ -244,7 +248,7 @@ export const retryAfterLine = ( entries: readonly { readonly cell: string; readonly value: string | null }[] ): string => entries.length === 0 - ? "`Retry-After` on a keyed 429: no 429 was answered." + ? "`Retry-After` on a keyed 429: no keyed 429 was answered." : `\`Retry-After\` on a keyed 429: ${entries .map( ({ cell, value }) => diff --git a/test/support/keyed-probes.ts b/test/support/keyed-probes.ts index 86002d85..595397c5 100644 --- a/test/support/keyed-probes.ts +++ b/test/support/keyed-probes.ts @@ -19,8 +19,16 @@ * `docs/engineering-notes.md`), which would read as "the path parsed it" and * measure nothing. * + * The variants ({@link KEYED_VARIANTS}) also carry the console study's rows + * (OC6, OC8, OC9): a raw session id, the session-only header set, the + * harness's own User-Agent, and three rows on a free model sent with NO key, + * inside the keyed tier, which are the only cells here without a credential. + * * Pure: no environment read and no fetch — the tier takes the variable's - * VALUE, and a key comes in as an argument. It reaches `src` only through the + * VALUE, and a key comes in as an argument. The one exception is read only + * when a cell is built, never at import: the OC6 rows' User-Agent is the + * installed dsh-llm's attribution ({@link harnessUserAgent}), which on the rc + * channel reads `DSH_USER_AGENT_SUFFIX`, as the host's own does. It reaches `src` only through the * test door (`./src.ts`), as `./keyless-matrix.ts` does, for the user agent a * turn carries and the SDK → protocol table the L10 variant's model is chosen * by, so the identity the live suite sends is defined here once @@ -31,6 +39,8 @@ * * @module test/support/keyed-probes */ +import { attributionHeaders } from "@deepseek-ai/dsh-llm"; + import { type AttemptVerdict, type MatrixCell, @@ -40,6 +50,7 @@ import { LEGACY_DISPATCH, TODAY_DISPATCH, isFreeSpec, + isFreeUsageLimit, isModelDeprecated, isUpstreamBusy, matrixUrl, @@ -47,6 +58,8 @@ import { } from "./keyless-matrix.ts"; import { type CatalogModelSpec, + DUMMY_BASH_TOOL_FUNCTION, + DUMMY_READ_TOOL_FUNCTION, OPENCODE_UA, openCodeSessionIdFor, protocolForSdk, @@ -223,6 +236,7 @@ export const keyedCell = ( export type KeyedVerdict = | "account" | "auth-refused" + | "free-usage-limited" | "model-gone" | "other" | "path-exists" @@ -264,6 +278,11 @@ const MODEL_NOT_FOUND = * - `rate-limited` is a 429, and `account` the account rather than the path: * no funds, or a model the account may not use. Neither says anything about * the shape. + * - `free-usage-limited` is a `429 FreeUsageLimitError` ({@link + * isFreeUsageLimit}): the free tier's usage cap refusing the caller, which + * is the gate's decision rather than a rate limit (lesson L3). Only a + * keyless free row meets it — a paid keyed cell never does — and like a + * rate limit it neither fails nor passes a plane ({@link planeOutcome}). * - `session-refused` is the request's session refused (`MissingSessionID`, a * `FreeTierError` on a paid model): the probe did not look like the plugin, * so it measured nothing about the path. @@ -304,7 +323,7 @@ export const classifyKeyedProbe = (answer: { return "model-gone"; } if (status === 429) { - return "rate-limited"; + return isFreeUsageLimit(bodyText) ? "free-usage-limited" : "rate-limited"; } if ( status === 402 || @@ -652,21 +671,68 @@ export const PROBE_PROJECTS = { folder: "my-app", } as const; +/** + * The raw session id the OC9 rows send: the shape the host mints for a + * conversation, `session-` (dsh-api-session-controller's + * `createMinted`, `session-${randomUUID()}`), the turn's `sessionId` the + * plugin derives its `ses_` id from (`src/session.ts`). Deliberately NOT a `ses_` id, so the row measures + * whether the gateway takes an id the plugin did not derive. + * + * Constant, so a run is reproducible, and chosen to sit OUTSIDE the session + * band the gateway admits for a `ses_` id (low twelve bits 0xFC0..0xFFE, + * `src/utils/session-id.ts`): its last three hex digits are 0x001, as a random + * id's almost always are, so a pass cannot come from a crafted in-band tail. + */ +export const PROBE_RAW_SESSION_ID = + "session-3b1f0c9e-5a2d-4c7e-9f10-a5c3d2e1f001"; + +/** + * The User-Agent the OC6 rows send: the harness's own attribution, which + * pi-ai applies to every provider request after every contributor + * (`attributionHeaders()` from the installed dsh-llm, so it names the + * channel's version). Read when a cell is built, not at import: on the rc + * channel the attribution reads `DSH_USER_AGENT_SUFFIX` and throws on a value + * that is not a token, which must not break a file that only imports this + * module. + * + * @returns the installed dsh-llm's `user-agent`. + */ +export const harnessUserAgent = (): string => { + const value = attributionHeaders()["user-agent"]; + if (value === undefined) { + throw new Error("the installed dsh-llm's attribution names no user-agent"); + } + return value; +}; + /** One keyed variant of the default completions cell. */ export interface KeyedVariant { readonly id: | "baseline" + | "free-baseline" | "project-digest" | "project-folder" | "reasoning-default" | "reasoning-none" - | "request-id"; + | "request-id" + | "session-only" + | "session-raw" + | "session-raw-free" + | "ua-harness" + | "ua-harness-free"; /** The lesson in `docs/improvement-plan.md` it measures. */ readonly lesson: string; /** What it changes, as the record's Probe column prints it. */ readonly label: string; - /** Which model it sends: the matrix's representative, or a reasoning one. */ - readonly model: "reasoning" | "representative"; + /** + * Which model it sends: the matrix's representative, a reasoning one, or + * a free one ({@link freeCompletionsModel}). + */ + readonly model: "free" | "reasoning" | "representative"; + /** `false`: sent with no credential header, the plugin's free turn. */ + readonly keyed?: false; + /** Identity headers removed from the probe identity. */ + readonly omit?: readonly string[]; /** Headers set over the probe identity. */ readonly headers?: Readonly>; /** Fields set over the keyed body. */ @@ -675,7 +741,9 @@ export interface KeyedVariant { /** * The variants the keyed suite sends on each plane's default completions - * cell, recorded and never asserted (P-R2 measures; P6 and P-R3 decide): + * cell, recorded and never asserted: the lessons' rows feed P6 (L4, L5) and + * the L10 follow-up, and the console study's rows an owner's OC6 decision, + * the session derivation's OC9 and P6's OC8: * * - L4 — `x-opencode-request`. The plugin sends a minted message id on every * turn, and one community plugin reports any value refused; the baseline @@ -688,8 +756,24 @@ export interface KeyedVariant { * offer Off on plain completions models. Its own baseline sends the same * model with no effort, so the pair differs in the one field. * + * - OC9 — a raw session id: the host's own conversation id + * ({@link PROBE_RAW_SESSION_ID}, `session-`) under + * both session headers instead of the derived `ses_` one, whether the + * gateway takes an id the plugin did not shape. + * - OC8 — the session-only header set: the probe identity without + * `x-opencode-client`, `x-opencode-project` and `x-opencode-session-id`, + * which is what a turn that only carries pi's own session header sends. + * - OC6 — the harness's User-Agent ({@link harnessUserAgent}) in place of + * the CLI's. + * - The free rows (OC6, OC9): a free completions model sent with NO key and + * the body the plugin's free turn sends ({@link freeTurnBody}), as the + * probe identity, with a raw session id, and with the harness's + * User-Agent. + * * Lesson L9, `Retry-After`, is read off every keyed 429 instead - * ({@link retryAfterOf}). + * ({@link retryAfterOf}); a keyless free row's 429 is not a keyed one, so + * the suite leaves it off that line. No row sends the vendor's `x-opencode-plugin` + * marker: that header is another plugin's. */ export const KEYED_VARIANTS: readonly KeyedVariant[] = [ { @@ -733,6 +817,61 @@ export const KEYED_VARIANTS: readonly KeyedVariant[] = [ lesson: "L10", model: "reasoning", }, + { + headers: { + "x-opencode-session": PROBE_RAW_SESSION_ID, + "x-opencode-session-id": PROBE_RAW_SESSION_ID, + }, + id: "session-raw", + label: "a raw session id (the host's `session-`, not `ses_…`)", + lesson: "OC9", + model: "representative", + }, + { + id: "session-only", + label: + "`x-opencode-session` only: no `x-opencode-client`, `x-opencode-project` or `x-opencode-session-id`", + lesson: "OC8", + model: "representative", + omit: ["x-opencode-client", "x-opencode-project", "x-opencode-session-id"], + }, + { + get headers(): Readonly> { + return { "user-agent": harnessUserAgent() }; + }, + id: "ua-harness", + label: "the harness's `User-Agent`", + lesson: "OC6", + model: "representative", + }, + { + id: "free-baseline", + keyed: false, + label: "a free model, no key: the probe identity and the free turn's body", + lesson: "OC6, OC9", + model: "free", + }, + { + headers: { + "x-opencode-session": PROBE_RAW_SESSION_ID, + "x-opencode-session-id": PROBE_RAW_SESSION_ID, + }, + id: "session-raw-free", + keyed: false, + label: "a free model, no key: a raw session id", + lesson: "OC9", + model: "free", + }, + { + get headers(): Readonly> { + return { "user-agent": harnessUserAgent() }; + }, + id: "ua-harness-free", + keyed: false, + label: "a free model, no key: the harness's `User-Agent`", + lesson: "OC6", + model: "free", + }, ]; /** @@ -755,6 +894,49 @@ export const reasoningCompletionsModel = ( !isDeprecated(spec.id) )?.id; +/** + * The free completions model the keyless rows send: the first catalog row + * that speaks completions, is free and is not deprecated. + * + * @param specs - the plane's catalog slice. + * @param isDeprecated - which ids the catalog retired (default: none). + * @returns the id, or `undefined` when the slice has none. + */ +export const freeCompletionsModel = ( + specs: readonly CatalogModelSpec[], + isDeprecated: (id: string) => boolean = () => false +): string | undefined => + specs.find( + (spec) => + protocolForSdk(spec.provider_npm) === "openai-completions" && + isFreeSpec(spec) && + !isDeprecated(spec.id) + )?.id; + +/** + * The body the plugin's free completions turn sends: the keyed body, + * streamed, with the free tier's `read` and `bash` and `tool_choice: "none"` + * (`src/tool-fallback.ts`). + * + * @param model - the free model. + */ +export const freeTurnBody = ( + model: string +): Readonly> => ({ + ...keyedBody("openai-completions", model), + stream: true, + tool_choice: "none", + tools: [DUMMY_READ_TOOL_FUNCTION, DUMMY_BASH_TOOL_FUNCTION], +}); + +/** The probe identity without the headers a variant omits. */ +const identityWithout = ( + omit: readonly string[] = [] +): Readonly> => + Object.fromEntries( + Object.entries(PROBE_IDENTITY).filter(([name]) => !omit.includes(name)) + ); + /** One variant, ready to send: a keyed cell and the variant it carries. */ export interface VariantCell extends MatrixCell { readonly variant: KeyedVariant; @@ -763,18 +945,22 @@ export interface VariantCell extends MatrixCell { /** * The variants' cells for one plane: each one the plane's default * completions cell (the modern family) made keyed ({@link keyedCell}) and - * then changed by exactly the variant's headers or body fields. A variant - * with no model keeps no body: it is never sent. + * then changed by exactly the variant's headers or body fields. A keyless + * row (`keyed: false`) carries the identity and no credential, and the free + * turn's body ({@link freeTurnBody}). A variant with no model keeps no body: + * it is never sent. * * @param plane - the plane. * @param models.representative - the matrix's completions model. * @param models.reasoning - {@link reasoningCompletionsModel}'s. + * @param models.free - {@link freeCompletionsModel}'s. * @param key - the plane's key. * @returns one cell per {@link KEYED_VARIANTS} entry, in order. */ export const keyedVariantCells = ( plane: MatrixPlane, models: { + readonly free?: string | undefined; readonly reasoning: string | undefined; readonly representative: string | undefined; }, @@ -782,19 +968,25 @@ export const keyedVariantCells = ( ): VariantCell[] => KEYED_VARIANTS.map((variant) => { const model = models[variant.model]; - const keyed = keyedCell( - { - body: undefined, - family: "modern", - headers: { "content-type": "application/json" }, - method: "POST", - model, - plane, - protocol: "openai-completions", - url: matrixUrl(plane, "openai-completions", "modern", model), - }, - key - ); + const cell: MatrixCell = { + body: undefined, + family: "modern", + headers: { "content-type": "application/json" }, + method: "POST", + model, + plane, + protocol: "openai-completions", + url: matrixUrl(plane, "openai-completions", "modern", model), + }; + const identity = identityWithout(variant.omit); + const keyed: MatrixCell = + variant.keyed === false + ? { + ...cell, + body: model === undefined ? undefined : freeTurnBody(model), + headers: { ...cell.headers, ...identity }, + } + : keyedCell(cell, key, identity); return { ...keyed, body: @@ -811,7 +1003,10 @@ export const keyedVariantCells = ( * value of every attempt answered 429, or `null` for one that carried none. * The host can honour a provider's delay (dsh-llm's `providerRetryAfterMs`) * but its pi bridge never fills it; whether the gateway sends one decides - * whether P-R3 should. + * whether the plugin should. The free tier's usage cap ({@link + * isFreeUsageLimit}) is left out: it is not a rate limit (L3), and a turn it + * refuses finishes as `FREE_TIER`, which nothing retries, so its delay is not + * L9's question. * * @param attempts - each attempt's answer, `undefined` for one that got none. * @returns one entry per 429, in order; empty when none was answered. @@ -820,5 +1015,7 @@ export const retryAfterOf = ( attempts: readonly (ProbeAnswer | undefined)[] ): (string | null)[] => attempts.flatMap((answer) => - answer?.status === 429 ? [answer.retryAfter ?? null] : [] + answer?.status === 429 && !isFreeUsageLimit(answer.bodyText) + ? [answer.retryAfter ?? null] + : [] ); diff --git a/test/support/src.ts b/test/support/src.ts index 016fb288..86f6af96 100644 --- a/test/support/src.ts +++ b/test/support/src.ts @@ -416,6 +416,18 @@ export { } from "../../src/utils/config-run.ts"; export type { ConfigRun } from "../../src/utils/config-run.ts"; +// utils/gateway-refusal.ts +export { + classifyGatewayRefusal, + FREE_TIER_CODE, + GATEWAY_REFUSAL_CODES, + leadingStatusOf, + MODEL_UNAVAILABLE_CODE, + reclassifyFinish, + reclassifyRefusals, + refusalTypeOf, +} from "../../src/utils/gateway-refusal.ts"; + // utils/guards.ts export { getAsyncIterator, diff --git a/test/tool-fallback.test.ts b/test/tool-fallback.test.ts index 7b45f6ae..285e0d16 100644 --- a/test/tool-fallback.test.ts +++ b/test/tool-fallback.test.ts @@ -40,6 +40,14 @@ import { import { parseJsonBody, toolNamesOf } from "./test-helpers.ts"; const RESPONSES_URL = `https://opencode.ai/zen/v1${RESPONSES_PATH}`; +const COMPLETIONS_URL = + "https://opencode.ai/inference/openai/v1/chat/completions"; + +/** A schema with one parameter: what makes a declared gate tool count. */ +const SCHEMA = { + properties: { q: { type: "string" } }, + type: "object", +} as const; /** * A free-tier model from the shipped Zen catalog, picked by its free flag @@ -359,6 +367,7 @@ describe("maybeInjectCoreTools: injection", () => { const declared = { description: "the caller's own bash", name: "bash", + parameters: SCHEMA, type: "function", }; const body = JSON.stringify({ model: FREE_MODEL, tools: [declared] }); @@ -380,8 +389,8 @@ describe("maybeInjectCoreTools: injection", () => { const body = JSON.stringify({ model: FREE_MODEL, tools: [ - { function: { name: "read" }, type: "function" }, - { function: { name: "bash" }, type: "function" }, + { function: { name: "read", parameters: SCHEMA }, type: "function" }, + { function: { name: "bash", parameters: SCHEMA }, type: "function" }, ], }); @@ -396,15 +405,19 @@ describe("maybeInjectCoreTools: injection", () => { expect(parseJsonBody(bodyText(out))).toEqual({ model: FREE_MODEL, tools: [ - { function: { name: "read" }, type: "function" }, - { function: { name: "bash" }, type: "function" }, + { function: { name: "read", parameters: SCHEMA }, type: "function" }, + { function: { name: "bash", parameters: SCHEMA }, type: "function" }, ], }); }); it("emits the OpenAI dialect when any entry uses it", () => { const headers = new Headers(); - const responsesShaped = { name: "read", type: "function" }; + const responsesShaped = { + name: "read", + parameters: SCHEMA, + type: "function", + }; const openAiShaped = { function: { name: "web" }, type: "function" }; const body = JSON.stringify({ model: FREE_MODEL, @@ -466,7 +479,7 @@ describe("maybeInjectCoreTools: injection", () => { it("skips tool entries it cannot read instead of dropping them", () => { const headers = new Headers(); - const declaredRead = { name: "read" }; + const declaredRead = { name: "read", parameters: SCHEMA }; const body = JSON.stringify({ model: FREE_MODEL, tools: [null, "bash", 7, declaredRead], @@ -474,8 +487,8 @@ describe("maybeInjectCoreTools: injection", () => { const out = maybeInjectCoreTools(RESPONSES_URL, body, headers, options()); - // Only an object with a name counts as declared, so the bare string - // "bash" does not stop a `bash` from being added. Entries the module + // Only an object with a name and a schema counts as declared, so the + // bare string "bash" does not stop a `bash` from being added. Entries the module // cannot read are carried through untouched: silently dropping a caller's // tool changes what the model is allowed to call. expect(parseJsonBody(bodyText(out))).toEqual({ @@ -485,6 +498,201 @@ describe("maybeInjectCoreTools: injection", () => { }); }); +describe("maybeInjectCoreTools: tool_choice and empty schemas (L7)", () => { + const grepTool = { + function: { name: "grep", parameters: SCHEMA }, + type: "function", + }; + const messages = [{ content: "hi", role: "user" }]; + + it("a free completions body with no tools of its own gets read, bash and tool_choice none", () => { + for (const raw of [ + JSON.stringify({ messages, model: FREE_MODEL }), + JSON.stringify({ messages, model: FREE_MODEL, tools: [] }), + ]) { + const headers = new Headers(); + const out = bodyText( + maybeInjectCoreTools(COMPLETIONS_URL, raw, headers, options()) + ); + expect(parseJsonBody(out)).toEqual({ + messages, + model: FREE_MODEL, + tool_choice: "none", + tools: [DUMMY_READ_TOOL_FUNCTION, DUMMY_BASH_TOOL_FUNCTION], + }); + expect(headers.get("content-length")).toBe( + String(Buffer.byteLength(out)) + ); + } + }); + + it("never sets tool_choice on Responses, nor on a completions body that brought tools of its own", () => { + const responses = parseJsonBody( + bodyText( + maybeInjectCoreTools( + RESPONSES_URL, + JSON.stringify({ input: "hi", model: FREE_MODEL }), + new Headers(), + options() + ) + ) + ) as Record; + expect(responses.tools).toEqual([DUMMY_READ_TOOL, DUMMY_BASH_TOOL]); + expect("tool_choice" in responses).toBe(false); + const chosen = parseJsonBody( + bodyText( + maybeInjectCoreTools( + COMPLETIONS_URL, + JSON.stringify({ + messages, + model: FREE_MODEL, + tool_choice: "auto", + tools: [grepTool], + }), + new Headers(), + options() + ) + ) + ) as Record; + expect(chosen.tool_choice).toBe("auto"); + expect(chosen.tools).toEqual([ + grepTool, + DUMMY_READ_TOOL_FUNCTION, + DUMMY_BASH_TOOL_FUNCTION, + ]); + const unchosen = parseJsonBody( + bodyText( + maybeInjectCoreTools( + COMPLETIONS_URL, + JSON.stringify({ messages, model: FREE_MODEL, tools: [grepTool] }), + new Headers(), + options() + ) + ) + ) as Record; + expect("tool_choice" in unchosen).toBe(false); + }); + + it("an empty-schema read or bash counts as missing and is replaced, never duplicated, in either dialect", () => { + const shellRead = { + function: { + name: "read", + parameters: { properties: {}, type: "object" }, + }, + type: "function", + }; + const completions = parseJsonBody( + bodyText( + maybeInjectCoreTools( + COMPLETIONS_URL, + JSON.stringify({ + messages, + model: FREE_MODEL, + tools: [shellRead, grepTool], + }), + new Headers(), + options() + ) + ) + ) as Record; + expect(completions.tools).toEqual([ + grepTool, + DUMMY_READ_TOOL_FUNCTION, + DUMMY_BASH_TOOL_FUNCTION, + ]); + // The request brought tools, so no tool_choice is set. + expect("tool_choice" in completions).toBe(false); + const responses = parseJsonBody( + bodyText( + maybeInjectCoreTools( + RESPONSES_URL, + JSON.stringify({ + model: FREE_MODEL, + tools: [{ name: "bash", type: "function" }], + }), + new Headers(), + options() + ) + ) + ) as Record; + expect(responses.tools).toEqual([DUMMY_READ_TOOL, DUMMY_BASH_TOOL]); + // A body whose read and bash both carry properties is left as it came. + const full = JSON.stringify({ + model: FREE_MODEL, + tools: [DUMMY_READ_TOOL, DUMMY_BASH_TOOL], + }); + expect( + maybeInjectCoreTools(RESPONSES_URL, full, new Headers(), options()) + ).toBe(full); + }); +}); + +describe("maybeInjectCoreTools: L7's edges on chat completions", () => { + const messages = [{ content: "hi", role: "user" }]; + + /** The completions body the injector answers for `fields`. */ + const injected = (fields: Record): Record => + parseJsonBody( + bodyText( + maybeInjectCoreTools( + COMPLETIONS_URL, + JSON.stringify({ messages, model: FREE_MODEL, ...fields }), + new Headers(), + options() + ) + ) + ) as Record; + + it("a malformed tools value counts as no tools: replaced by read and bash, with tool_choice none", () => { + for (const tools of ["read", null, { name: "read" }]) { + expect(injected({ tools }), JSON.stringify(tools)).toEqual({ + messages, + model: FREE_MODEL, + tool_choice: "none", + tools: [DUMMY_READ_TOOL_FUNCTION, DUMMY_BASH_TOOL_FUNCTION], + }); + } + }); + + it("replaces a toolless body's own tool_choice with none: it had no tools to choose from", () => { + for (const choice of ["required", "auto"]) { + expect(injected({ tool_choice: choice }).tool_choice, choice).toBe( + "none" + ); + } + }); + + it("a gate tool whose parameters declare no properties is an empty shell, replaced", () => { + const shellBash = { + function: { name: "bash", parameters: { type: "object" } }, + type: "function", + }; + const grepTool = { + function: { name: "grep", parameters: SCHEMA }, + type: "function", + }; + expect(injected({ tools: [grepTool, shellBash] }).tools).toEqual([ + grepTool, + DUMMY_READ_TOOL_FUNCTION, + DUMMY_BASH_TOOL_FUNCTION, + ]); + }); + + it("keeps no tool_choice when the only tools the turn declared were empty shells: it declared tools", () => { + const out = injected({ + tools: [ + { function: { name: "read" }, type: "function" }, + { function: { name: "bash", parameters: {} }, type: "function" }, + ], + }); + expect(out.tools).toEqual([ + DUMMY_READ_TOOL_FUNCTION, + DUMMY_BASH_TOOL_FUNCTION, + ]); + expect("tool_choice" in out).toBe(false); + }); +}); + describe("maybeInjectCoreTools: marker scoping", () => { it("injects for every model under the all-models marker", () => { const headers = new Headers(); diff --git a/vite.config.ts b/vite.config.ts index 0099d9e4..3cd86457 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -306,6 +306,14 @@ export default defineConfig({ functions: 100, lines: 100, }, + // The gateway's refusals (P-R3): every branch is one refusal type or + // one text shape. + "src/utils/gateway-refusal.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": {