diff --git a/.freebuff/project-id b/.freebuff/project-id deleted file mode 100644 index b164097c..00000000 --- a/.freebuff/project-id +++ /dev/null @@ -1 +0,0 @@ -a689d3ef-ace3-4ee0-bdef-783c86931947 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5f0ba30..18cafaad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,9 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # No `version:` input: pnpm/action-setup refuses to run when it is given - # both that and a `packageManager` field, and package.json pins 12.6.0 - # exactly. One source of truth, and the action reads it. + # both that and a `packageManager` field, and package.json pins the pnpm + # version exactly, in `packageManager`. One source of truth, and the + # action reads it. - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: @@ -60,6 +61,29 @@ jobs: path: coverage retention-days: 7 + # `engines.node` promises >=24 and every other job runs 26, so the floor was a + # claim nothing exercised. The suites that depend on the engine (node:sqlite, + # Intl.DurationFormat and its ICU strings) are exactly the ones that differ + # between majors, so the floor gets its own offline run of the unit and mock + # suites. Raise this together with `engines.node`. + engine-floor: + name: test · node 24 (engines floor) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + # Shuffled here, and in order in the main job: the two runs together show a + # case that passes only because of what an earlier case left behind. The + # seed is printed, so a failure reproduces with `--sequence.seed=`. + - run: pnpm run test:shuffle + - run: pnpm run test:mock + # The bundled catalog is generated from models.dev, and the one field that went # stale before (`provider_npm`) put nine models on an endpoint that cannot # serve them — a WRONG answer rather than a missing one, and invisible to the @@ -67,21 +91,51 @@ jobs: # diff to review instead of a bug to diagnose. # # WHAT THIS GATES, and what it deliberately does not. The failure it exists - # for is an OMISSION: someone changes the catalog and forgets to regenerate, - # so the wrong `provider_npm` ships. That is fixable before merging, so a - # PULL REQUEST fails on a stale shim. + # for is an OMISSION: someone changes a generator - or the parser it calls - + # and forgets to regenerate, so the wrong `provider_npm` ships. That is a fact + # about a pull request's OWN diff, so the diff decides. On a pull request each + # of the three checks (`catalog:shim`, `limits:shim`, `cli:version`) GATES + # only when the diff touches one of that check's INPUTS: its script and every + # local module the script loads, transitively (a type-only import loads + # nothing), plus `src/identity.ts`, which `cli:version` reads as data. + # Otherwise it REPORTS - a warning, exit 0. One half of `cli:version` gates + # every pull request regardless: the offline scan that every copy of the CLI + # version in the tree agrees with the pin, which reads only the files the + # repository maintains (never the generated tables, which hold vendor data), + # so no vendor move can break it. + # `scripts/freshness-scope.ts` derives the inputs from the sources themselves + # and makes the call, so adding an import to a generator moves its inputs + # without anyone editing a list. The generated files are never inputs: a pull + # request that only carries a refresh reports drift rather than failing on it, + # and one that edits a generated file without touching its generator gets a + # warning naming the file, since a hand-edit is forbidden. + # + # Why a pull request is not gated on drift alone: the vendor's catalog, its Go + # limits page and its CLI version move most days, and each move used to turn + # every open pull request red although none of them caused it. Vendor drift + # is not a pull request's regression. The nightly run notices all three. For + # the two generated tables it regenerates them and proposes the refresh as a + # pull request of its own, which is where it is reviewed. For the CLI version + # it only warns: the bump is a human pull request to `src/identity.ts` (and + # its copies), which gates, because the pin is one of the check's inputs. # - # A push to main is a different question. `main` was reviewed with a current - # shim; the vendor may have rotated rows in the minutes since. The runtime - # already refreshes the catalog on its own (SWR, 1h TTL), so a shim a few - # hours behind only affects a cold start - it is not a regression the push - # introduced. Failing there turned upstream churn into a red main several - # times in one afternoon, which is how a signal gets ignored. So on main it - # REPORTS (a warning, and the nightly proposes the refresh) rather than - # failing. + # The gates FAIL CLOSED. Each runs unless the scope step answered "report", + # so a lost answer (the step moved below its readers, an output renamed) + # falls back to gating every pull request, which is loud, rather than to + # running nothing, which is not. # - # Fork PRs are skipped: the job reaches the public internet, and a fork's - # copy would answer "stale" for everyone. + # A push to main is the same question with the same answer. `main` was + # reviewed with a current shim; the vendor may have rotated rows in the + # minutes since. The runtime already refreshes the catalog on its own (SWR, 1h + # TTL), so a shim a few hours behind only affects a cold start - it is not a + # regression the push introduced. Failing there turned upstream churn into a + # red main several times in one afternoon, which is how a signal gets + # ignored. So on main it REPORTS (a warning, and the nightly proposes the + # refresh) rather than failing. + # + # Fork PRs run the same scoped checks: they read only public sources, and the + # job's write access is used by the nightly path alone, which a fork never + # triggers. catalog: name: catalog freshness runs-on: ubuntu-latest @@ -93,62 +147,113 @@ jobs: pull-requests: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Two commits: on a pull request HEAD is the merge ref, whose first + # parent is the base, so `HEAD^1..HEAD` is exactly the PR's own diff. + # The default depth of one would leave `HEAD^1` missing. + fetch-depth: 2 - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 26 cache: pnpm - run: pnpm install --frozen-lockfile - - name: is src/catalog-data.ts current? (pull requests) + - name: which checks does this pull request gate? (pull requests) + id: scope if: github.event_name == 'pull_request' run: | - # Exits 1 on a stale shim and prints what would change. A PR is where - # a forgotten regeneration is still fixable, so this one is a gate. + changed="$RUNNER_TEMP/changed-files" + git diff --name-only HEAD^1 HEAD > "$changed" + # Printed, so the decision below can be checked against what it read. + echo "this pull request changes $(wc -l < "$changed") file(s):" + cat "$changed" + # decide : "gate" when the diff touches one of + # the check's inputs, "report" otherwise. Any warning the script + # raises (a generated file changed without its generator) goes to + # stderr, kept apart from the answer and printed after it. + decide() { + notes="$RUNNER_TEMP/scope-notes" + scope=$(node --experimental-strip-types --disable-warning=ExperimentalWarning \ + scripts/freshness-scope.ts "$2" < "$changed" 2> "$notes") \ + || { cat "$notes"; exit 1; } + cat "$notes" + case "$scope" in + gate | report) ;; + *) echo "::error::freshness-scope answered '$scope' for $2"; exit 1 ;; + esac + echo "$2: $scope" + echo "$1=$scope" >> "$GITHUB_OUTPUT" + } + decide catalog catalog:shim + decide limits limits:shim + decide cli cli:version + - name: is src/catalog-data.ts current? (pull requests touching its generator) + if: github.event_name == 'pull_request' && steps.scope.outputs.catalog != 'report' + run: | + # Exits 1 on a stale shim and prints what would change. This pull + # request changed what generates the shim, so a forgotten + # regeneration is its own omission, and still fixable: a gate. pnpm run catalog:shim || { echo "::error::src/catalog-data.ts is out of date — run 'pnpm run catalog:shim -- --write' and commit the result." exit 1 } - - name: report a stale catalog (pushes to main) - if: github.event_name == 'push' + - name: report a stale catalog (other pull requests, and pushes to main) + if: github.event_name == 'push' || (github.event_name == 'pull_request' && steps.scope.outputs.catalog == 'report') run: | - # A warning, not a failure: main was reviewed with a current shim, and - # the vendor moving since then is not a regression this push caused. - # The nightly run proposes the refresh. + # A warning, not a failure. A pull request that touched no input of + # the generator, or a push to main: vendor drift is not a regression + # of the change under test. The nightly run proposes the refresh. pnpm run catalog:shim \ - || echo "::warning::src/catalog-data.ts is stale - the registry moved since this commit; the nightly refresh will propose it." + || echo "::warning::src/catalog-data.ts is stale - the registry moved; this run does not gate on vendor drift, and the nightly refresh will propose it." # The Go plan's per-model dollar allowance, from OpenCode's own docs. It # moves when THEY move — the page says limits "may change as we learn from # early usage and feedback" — and a stale table shows a wrong dollar figure # that looks authoritative, which is worse than showing none. - - name: is src/go-limits-data.ts current? (pull requests) - if: github.event_name == 'pull_request' + - name: is src/go-limits-data.ts current? (pull requests touching its generator) + if: github.event_name == 'pull_request' && steps.scope.outputs.limits != 'report' run: | pnpm run limits:shim || { echo "::error::src/go-limits-data.ts is out of date — run 'pnpm run limits:shim -- --write' and commit the result." exit 1 } - - name: report a stale limits table (pushes to main) - if: github.event_name == 'push' + - name: report a stale limits table (other pull requests, and pushes to main) + if: github.event_name == 'push' || (github.event_name == 'pull_request' && steps.scope.outputs.limits == 'report') run: | pnpm run limits:shim \ - || echo "::warning::src/go-limits-data.ts is stale - the vendor's docs page moved since this commit." + || echo "::warning::src/go-limits-data.ts is stale - the vendor's docs page moved; this run does not gate on vendor drift, and the nightly refresh will propose it." # The nightly run exists to NOTICE vendor drift, and drift is the normal # case - the sources move most days. Failing here meant a red run every # night, which trains everyone to ignore the signal. So the nightly path - # regenerates and proposes a pull request, and the refresh gates above - # run on pull requests (where a forgotten regeneration is still fixable) - # while a push to main only reports. + # regenerates and proposes a pull request; the refresh gates above run on + # a pull request that touches a generator (where a forgotten regeneration + # is still fixable); any other pull request, and a push to main, only + # reports. A manual dispatch runs neither shim check - only the CLI + # version's report below. # # The User-Agent names the CLI version, and the vendor moves it every few - # days - which is the same shape again: a vendor release is not a reason - # to go red, so this one warns everywhere except a pull request. - - name: the claimed CLI version is the published one (pull requests) + # days - which is the same shape again, with one difference: the script + # checks two things. Every copy of the version in the tree agreeing with + # `src/identity.ts` is offline, and only the repository can break it, so + # it gates every pull request. The pin being the published version follows + # the vendor, so it gates only a pull request that touches the pin or the + # script, and warns everywhere else - quoting the script's own `error:` + # line, so the warning names the cause that failed. + - name: every copy of the CLI version agrees with the pin (pull requests) if: github.event_name == 'pull_request' + run: pnpm run cli:version -- --copies + - name: the claimed CLI version is the published one (pull requests touching it) + if: github.event_name == 'pull_request' && steps.scope.outputs.cli != 'report' run: pnpm run cli:version - - name: report a stale CLI version (push and nightly) - if: github.event_name != 'pull_request' - run: pnpm run cli:version || echo "::warning::the pinned opencode CLI version is not the published one" + - name: report a stale CLI version (other pull requests, push, nightly and dispatch) + if: github.event_name != 'pull_request' || steps.scope.outputs.cli == 'report' + run: | + # The default shell has no pipefail, without which `tee` would answer + # for the check and the warning below could never fire. + set -o pipefail + log="$RUNNER_TEMP/cli-version.log" + pnpm run cli:version 2>&1 | tee "$log" \ + || echo "::warning::$(grep -m 1 '^error: ' "$log" || echo 'cli:version failed - see the log above')" - name: propose the refresh (nightly only) if: github.event_name == 'schedule' diff --git a/.gitignore b/.gitignore index 8c33bb8f..ecb00ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage/ # Agent workspace: session notes and scratch data, not source. .workbuddy-ai/ +.freebuff/ diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..6f4247a6 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +26 diff --git a/AGENTS.md b/AGENTS.md index 6735f340..712bdfaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: src/utils/ a function of its arguments. No context, no globals, no I/O. src/ effects: read a context, ask a utility, cause something. -**The membership test for `utils/` is a property, not a vibe:** no `node:*`, no `@deepseek-ai/cordis`, no `process.env`, no imports from outside the folder except `guards.ts`. `test/architecture.test.ts` walks the tree and fails the build otherwise — verified by planting a violation of each kind and watching it fail. +**The membership test for `utils/` is a property, not a vibe:** no `node:*`, no `@deepseek-ai/cordis`, no `process.env`, no `fetch`, no reference to `console` or a global timer (`setTimeout`/`setInterval`/`setImmediate` and their `clear*` counterparts, called or passed as a value, bare or through `globalThis.`/`window.`/`global.`/`self.`), no relative import that resolves outside the folder (`guards.ts` lives inside it). A utility that needs a clock or a logger takes it as a parameter, the way `utils/retry.ts` takes its `sleep` and `warn` and leaves the timer to the caller. `test/architecture.test.ts` walks the folder recursively, resolves each import from the importing file's own directory, and fails the build otherwise — verified by planting a violation of each kind and watching it fail. It is the same trick `scripts/check.ts` already uses for the client bundle, at finer grain. @@ -67,12 +67,13 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f ## What moved -| to `utils/` | from | -| :----------------- | :------------------------------------------------ | -| `request-shape.ts` | new (extracted in #74) | -| `guards.ts` | `src/` | -| `session-id.ts` | the rules inside `session.ts` | +| to `utils/` | from | +| :-- | :-- | +| `request-shape.ts` | new (extracted in #74) | +| `guards.ts` | `src/` | +| `session-id.ts` | the rules inside `session.ts` | | `google-replay.ts` | `src/` (pure translation, so the gate polices it) | +| `retry.ts` | new (7b1c5d4): the settings card's usage-read retry loop; its default `sleep` reached a global timer and its warning reached `console` until P1b moved both to the caller (`backoffOver(timer)`, `warn`) | - `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. @@ -85,8 +86,8 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f - `src/go-limits.ts` — the allowance lookup, the label normalization and the money formatting. **One lookup, and it is the tested one**: `attachAllowance` in `usage.ts` used to inline the same expression, and the copy nothing called was the tested one; it now goes through `allowanceFor`. The row's label is the model the allowance belongs to — the picked one, by the catalog's spelling — because a figure beside another model's name states something false about the money. - `src/go-limits-data.ts` — the Go plan allowances, per model and per tier, **generated** from the vendor's own page by `scripts/generate-go-limits.ts`; never hand-edit it, and regenerate with `pnpm run limits:shim -- --write`. The scheduled freshness job is what notices when the page moves, which it did: the docs went under `/v2/docs/console/` and serve HTML now, so the generator reads a table off that page rather than the markdown it used to. +- `scripts/freshness-scope.ts` — which of CI's three freshness checks (`catalog:shim`, `limits:shim`, `cli:version`) a pull request is GATED on: the ones whose inputs (the check's script and the local modules it loads, walked from the source as Node loads it — types erased by Node's own stripper, specifiers read by `es-module-lexer` — plus `src/identity.ts` for the CLI version; never the generated files) its own diff touches. The rest only warn, because vendor drift is not the pull request's regression: for the two generated tables the nightly run regenerates them and proposes the refresh, and for the CLI version it warns and the bump is a human pull request, which then gates. The gates fail closed: each runs unless the scope step answered `report`. The CLI version's offline half — every copy in the tree agrees with the pin, `cli:version -- --copies` — gates every pull request, since it skips the generated tables and only the repository can break it. `test/freshness-scope.test.ts` walks the real generators, runs the scope step's own shell and the CLI it calls, and evaluates the workflow's step conditions. - `src/responses-routes.ts` · `src/responses-provider.ts` — the protocol table and the in-process mount of the host's `llm-pi-ai`. -- `src/adapter-replacement.ts` — taking a provider route over from the adapter that already serves it: `llm.registerAdapter` refuses a route the host owns (`DUPLICATE_ADAPTER`), so the current entry is released first, our adapter is registered through the ordinary API, and the original is put back on disposal. The step towards serving a mixed-protocol route as the same provider the user configured, instead of mounting hidden ones. - `src/purge-service.ts` · `src/purge-contract.ts` — the card's "purge deprecated models" action, and the shape it answers with. It **rewrites the profile**, deleting the declared model entries the live catalog has retired, through `configEditor` (the same service the settings UI writes with). That is deliberate: a read-time filter would leave the declaration in place, and then the only way to explain a missing row is to find the filter. One source of truth, and it is the configuration. - `src/session-cost.ts` · `src/cordis-context.ts` · `src/debug.ts` — per-turn accounting, the typed host interfaces, and stream debug logging. - `src/pi-provider.ts` — the per-protocol base URLs, the pi model object, and the gateway headers. Every value here is measured; see `docs/redirect-planes.md`. @@ -96,18 +97,19 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f - `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. - **What the plugin page shows is `docs/detail.md`, not the README.** The page renders one document: the Markdown `meta.detail` in `locale/.json` names, else the whole `README.md`. This package declared nothing for years, so its page carried 461 lines of engineering notes above the settings card. It now declares a page per language that is **one image and nothing else** — a UI preview of the card and the meter (`docs/media/preview.svg`, `.zh.svg`) — which is the shape the upstream plugins use, because the header above the card already carries the title and the one-sentence description. The image is served from the package, so `files` has to ship it (the detail-image route reads package-relative paths). `test/plugin-detail.test.ts` holds the contract: a declared detail in every locale, a path that exists AND is shipped, an image that does the same, and a page that is the picture and nothing else. - `src/opencode-store-contract.ts` · `src/opencode-store-service.ts` — the inspection seam: the sanitized summary type both sides share, and the host service that produces it. The contract is pure data (no `node:`, no context), so the client bundle may import it; the service is the half that touches `node:sqlite` and the filesystem, and it rewrites the store path to `~`-relative before answering — a home directory is a fact about the machine, not about the plugin. `inspect` is declared through the `Object.defineProperty` marker rather than `@Remote`, for the reason `usage.ts` documents. -- **The login stack is not in this tree.** It lives on the `v2` bookmark, and the map used to describe it here for years after it left — nine modules a reader could not find. What this tree carries is the API-key path only; a login grant is the other half, and reviving it is a days-scale port rather than a rebase (`v2` is 163 commits behind, and its `src/index.ts` exports a module the tree no longer has). +- **The login stack is not in this tree.** It lives on the `v2` bookmark, and the map used to describe it here for years after it left — nine modules a reader could not find. What this tree carries is the API-key path only; a login grant is the other half, and reviving it is a days-scale port rather than a rebase (`v2` is 163 commits behind, and its `src/index.ts` exports a module the tree no longer has). The settings copy still holds its strings, and `credentialOrg` is the one remnant with behaviour: a configured org is sent as `x-opencode-org-id`. -- **The meter's remote is a timing dependency worth knowing before debugging a row that does not appear.** The mount is an async `$mount` started in an effect, and the card reads `ctx.remote.opencodeGoUsage` SYNCHRONOUSLY when its injector builds the props. If the injector runs before the mount resolves, the service is not there yet and the surface renders nothing. Both halves now report when the lookup comes back empty — `no opencodeGoUsage service on the remote` with the keys the remote does expose — so the symptom is a line in the console rather than a blank. +- A timing dependency every remote shares, worth knowing before debugging a row that will not appear: the mount is an async `$mount` started in an effect, and the card reads `ctx.remote.` SYNCHRONOUSLY when its injector builds the props. If the injector runs before the mount resolves, the service is not there yet and the surface renders nothing. More often the cause is a namespace with no client descriptor at all — the purge row and the store-inspection row each shipped that way — which `test/remote-contract.test.ts` now catches per method. Each caller reports an empty lookup (`no opencodeStore service on the remote`, `no opencodeGoUsage service on the remote`, with the keys the remote does expose), so the symptom is a console line rather than a blank. -Web client bundle (`lib/client.js`): `src/settings-page.tsx` (entry, wiring only), the `settings-*` card and its controls, and the meter — `src/usage-pill.tsx` (state), `src/usage-panel.tsx` (pure views), `src/usage-ui.ts` (logic and stylesheet). +Web client bundle (`lib/client.js`): `src/settings-page.tsx` (entry, wiring only), the `settings-*` card and its controls — including the store-inspection row, `src/settings-store-inspect.tsx` — and the meter — `src/usage-pill.tsx` (state), `src/usage-panel.tsx` (pure views), `src/usage-ui.ts` (logic and stylesheet). `test/architecture.test.ts` walks this graph from the entry and fails on a node built-in, the cordis runtime, pi, or `process.env`. ## Commands ```sh -vp test run # unit suite +vp test run # unit suite (refuses to start below engines.node) +pnpm run test:shuffle # the unit suite in random order; CI runs it on Node 24 pnpm run test:mock # gateway mock suite: offline, no key, runs in CI -vp test run --coverage # with the coverage ratchet (95/90/93/95) +vp test run --coverage # with the coverage ratchet (thresholds in vite.config.ts) vp check # format, lint, types vp fmt --write # rewrite formatting pnpm run build # build lib/ (host bundle + client bundle) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b8ab64d..09434256 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Prerequisites -- Node.js `>= 24`, `pnpm` (v12) +- Node.js `>= 24` (CI runs the version in `.node-version`, and the floor in a job of its own), `pnpm` (v12). The suite refuses to start below the floor and names both versions: below it, `node:sqlite`, `Intl.DurationFormat` and the ICU strings the tests pin differ, and the failures read like regressions. - A checkout of this repo; for live testing, a DeepSeek Harness profile (see README for the `link:` setup) ## Commands @@ -14,9 +14,13 @@ pnpm install # install dependencies pnpm run check # format + lint + types; must be zero *errors* (warnings are reported, not gating) pnpm run test # Vitest suite, must be fully green and deterministic pnpm run test:coverage # the same run, with the coverage ratchet enforced +pnpm run test:shuffle # the same run in a random order; reproduce a failure with --sequence.seed= +pnpm run test:mock # gateway mock suite: offline, loopback only, runs in CI pnpm run build # vp pack + client rename -> lib/index.mjs, lib/index.d.mts, lib/client.js pnpm run test:e2e # opt-in end-to-end suite; talks to the real OpenCode gateway -pnpm run catalog:shim # regenerate src/catalog-data.ts from models.dev +pnpm run catalog:shim # is src/catalog-data.ts current? (-- --write regenerates it from models.dev) +pnpm run limits:shim # is src/go-limits-data.ts current? (-- --write regenerates it) +pnpm run cli:version # is the pinned OpenCode CLI version the published one? ``` > Note: in some shells `pnpm exec` stalls; invoke the binary directly if so: `node node_modules/.pnpm/vite-plus@*/node_modules/vite-plus/bin/vp `. @@ -34,10 +38,13 @@ OPENCODE_E2E=1 OPENCODE_API_KEY=… OPENCODE_GO_API_KEY=… pnpm run test:e2e - The **free-tier and unmetered** routing cases run keyless on purpose: measured 2026-10-06, a paid model answers `401 AuthError` on every endpoint without a credential, so keyless probing cannot tell them apart at all. Keyless discrimination is real only for the free classes. - `OPENCODE_ZEN_BASE_URL` / `OPENCODE_GO_BASE_URL` retarget the suite at a mirror. -Two files, two jobs: +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. - `patched-fetch-headers.e2e.ts` — a local `node:http` listener, so the _outgoing_ header set crosses a real socket. The live gateway cannot echo a request back; everywhere else in the suite the injected headers are asserted against a captured `fetch`. +- `protocol-routing.e2e.ts` — one turn per protocol against the live gateway: the vendor-side claim that a model's `provider_npm` names its wire format, which a unit test can only read back from our own table. +- `refusal-classification.e2e.ts` — the host's error classifier fed the gateway's measured refusals over a real `node:http` socket, chunked bodies included, after asserting the installed bundle still carries the branches the verdicts rely on. +- `dispatch-levels.e2e.ts` — no network: the installed pi's own level rule against both decorators across the shipped catalog, so every reasoning level the picker offers is one dispatch accepts. ## Live profile loop @@ -45,12 +52,12 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Host (`lib/index.mjs`) — restart required.** The base bundle ships `hmr root: []` (config watches only, `**/node_modules` ignored) and the loader caches ESM imports per process, so a linked package is never re-imported after boot. After every `pnpm run build`, restart `dsh web` — otherwise the profile silently keeps running the module from boot time. Symptom of a stale host: the plugin row is active but the settings card shows "This plugin is not loaded, so it cannot be configured." — `dsh-settings` is filtering on the old module's `Config` schema. - **Client (`lib/client.js`) — refresh suffices.** The client bundle is re-served from disk on every page load; a browser refresh picks it up. -- **Verify after a restart:** the Plugins page shows the **OpenCode Patch** card, and its configure view renders all 17 fields (no unavailable line). `node --experimental-strip-types scripts/check.ts` catches a stale `lib/` before you do (`src/` newer than `lib/`). +- **Verify after a restart:** the Plugins page shows the **OpenCode Patch** card, and its configure view renders every card field (`CARD_FIELDS` in `src/settings-fields.ts`; the `CONFIG_ONLY_FIELDS` knobs stay off the card by design) and no unavailable line. `node --experimental-strip-types scripts/check.ts` catches a stale `lib/` before you do (`src/` newer than `lib/`). ## Code conventions - **100% strict TypeScript.** No `any` leaks, no `as` assertions in `src/` — narrow `unknown` with `in`-operator type guards (`isRecord`, `isUnknownArray`, …). -- **Zero errors and zero warnings.** `vp check` must report neither — that is what `release:gate` and CI enforce, and the warnings tier has been paid down to zero: do not reintroduce one. The `error` tier is reserved for defects: async safety and throw contracts. If a rule fights a correct pattern (e.g. sync Promise wrappers that preserve `AsyncLocalStorage` context), prefer a targeted `oxlint-disable` comment with justification over weakening the rule globally. +- **Zero errors; warnings are reported, not gating** (the `pnpm run check` line above). `release:gate` and CI fail on an error; warnings become gating in phase P12 of `docs/improvement-plan.md`, so do not add one. The `error` tier is reserved for defects: async safety and throw contracts. If a rule fights a correct pattern (e.g. sync Promise wrappers that preserve `AsyncLocalStorage` context), prefer a targeted `oxlint-disable` comment with justification over weakening the rule globally. - **Sync-over-async for context propagation.** `withStore` iterators and the `fetch` patch intentionally return promises from non-`async` functions so `als.run()` keeps turn context without an extra tick. Don't "fix" these into `async`. - **Style:** arrow-function consts (not `function` declarations), dot notation, explicit `=== undefined` checks, `oxfmt` formatting. @@ -59,6 +66,11 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Deterministic only.** No `Math.random()`, no fixed `sleep()` waits. Async file assertions poll with a deadline (`waitForFileContent`). - **No secret fixtures.** Session IDs are derived at runtime (`openCodeSessionIdFor`) or read from `OPENCODE_SESSION_ID`; never hardcode `ses_…` or API keys. `lib/` and `*.log` stay gitignored. - **Restore globals.** Tests that touch `globalThis.fetch` or `process.env` must restore them in `afterEach` (`vi.unstubAllGlobals()`, `delete process.env.…`). +- **Offline by construction.** `test/support/offline.ts` refuses every non-loopback request in the unit and mock suites. The catalog's background revalidation used to reach `models.opencode.ai` dozens of times per run, so a networked runner's results could depend on the vendor's live catalog. A case that means to fetch stubs `fetch` itself. +- **No order dependence.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test. A new module-level store gets a reset next to its siblings. `pnpm run test:shuffle` is how to check, and CI runs it. +- **Tests reach `src` through `test/support/src.ts`.** It re-exports, from each defining module, everything a test needs, so a module move or a barrel change edits one file instead of thirty. A new test imports from `./support/src.ts` (`../support/src.ts` under `test/mock` and `test/e2e`); add a missing name there rather than importing a `src` path. The exceptions are `vi.mock("../src/x.ts")` targets (and their `importOriginal` type), which must name the module; the architecture tests, whose subject is the `src` layout itself; the jsdom (client) tests, which import the defining module directly, because `src.ts` re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and the next rule keeps that out of client tests; and the setup file `test/support/reset-process-state.ts`, which runs before the jsdom tests too. Importing the door loads the whole `src` graph, host and client, so a module that fails to load fails every converted file; a client-safe door, limited to what the bundle gate admits, is P2's to add if that cost shows. The remaining pre-existing direct imports are converted in P2. +- **Host-only fixtures stay out of shared helpers.** `test/test-helpers.ts` is imported by the jsdom (client) tests, so anything that needs `node:sqlite` or the filesystem belongs in `test/support/`, as `opencode-store-fixture.ts` does. +- **A test for a fix fails without it.** Before committing a regression test, revert the fix locally and watch it fail. - **Meaningful coverage.** Every config field in the `SPECS` register (`src/settings-fields.ts`) needs both the on and off path where it has one; every passthrough claim needs a non-OpenCode URL test proving headers are untouched; helper functions exported from `usage-ui.ts` are asserted directly, not re-derived in the test. - **A component with hooks needs a real mount.** Calling `Component(props)` returns an element and runs none of its state, so a hook-bearing component can have twenty passing cases and still be untested — `usage-pill.tsx` sat at 9% exactly that way, with the poll loop, retry and dismissal all uncovered. `test/usage-pill-mount.test.tsx` is the answer: a `// @vitest-environment jsdom` pragma scoped to that one file, so the rest of the suite keeps the fast node environment and the zero-dependency element-tree style. @@ -71,11 +83,29 @@ The web profile wires this checkout with `link:`, so builds are picked up like t `src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. `responses-provider.ts` and `responses-routes.ts` carry their own per-file floors: the mount has four host contracts to survive, and a well-covered average must not be able to hide it. +## The freshness checks in CI + +The `catalog freshness` job runs three checks against what the vendor publishes: `catalog:shim` (the bundled catalog, `src/catalog-data.ts`), `limits:shim` (the Go allowances, `src/go-limits-data.ts`) and `cli:version` (the pinned CLI version in `src/identity.ts`). The vendor moves all three most days, so what a run does with a mismatch depends on whether the change under test could have caused it. + +- **A pull request is gated on a check only when its own diff touches that check's inputs.** The inputs are the check's script and every local module it loads, transitively, plus `src/identity.ts` for `cli:version`, which the script reads as data. The edges are the ones Node loads: the source's types are erased by Node's own stripper and the specifiers read by a real lexer, so a whole-statement `import type` loads nothing and is no edge, and an import written in a string or template is not one either; anything else is, so the set reaches further than the generator's own folder: the catalog parser loads `src/config.ts`, so a settings change gates `catalog:shim`. `scripts/freshness-scope.ts` derives the inputs from the sources and answers `gate` or `report` for the diff (`git diff --name-only HEAD^1 HEAD` on the merge ref). Change `src/models-catalog.ts` and forget to regenerate, and the pull request fails; that omission is what the gate exists to catch. +- **One half of `cli:version` gates every pull request.** The script checks two things: that every copy of the CLI version in `src/`, `test/`, `docs/`, `scripts/` and `README.md` agrees with `src/identity.ts`, and that the pin is the published version. The first is offline and skips the two generated tables (they hold the vendor's data, not copies the repository maintains), so only the repository can break it, and `pnpm run cli:version -- --copies` runs on every pull request as a gate. Only the registry comparison is scoped as above, and when it only warns, the warning quotes the script's own `error:` line. +- **Otherwise the check reports.** A stale file is a `::warning::`, the job stays green, and the nightly run regenerates both files and opens the refresh as a pull request of its own. A stale CLI pin is only warned about, by every run including the nightly; bumping it is a human pull request to `src/identity.ts` and its copies, which is then gated, because the pin is an input. Vendor drift is not a regression your pull request introduced, so it is not a reason to fail it. +- **The generated files are never inputs.** A pull request that carries only a refresh reports. One that changes `src/catalog-data.ts` or `src/go-limits-data.ts` without touching what generates it gets a warning naming the file, because those files are never edited by hand. +- **A push to `main` reports.** The nightly run regenerates the two generated files and opens the refresh as a pull request; for the CLI version it warns. A manual dispatch only reports the CLI version. None of these paths gates, for the same reason. +- **The gates fail closed.** Each runs unless the scope step answered `report`, so a lost answer gates every pull request rather than skipping the check. + +To see the answer for your branch before CI does: + +```sh +git diff --name-only main... \ + | node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/freshness-scope.ts catalog:shim +``` + ## Release process (maintainers) 1. Use conventional commits (`feat:`, `fix:`) — release-please opens the version-bump + changelog PR automatically; merging it tags and creates the GitHub Release, which fires OIDC publishing to npm and GitHub Packages. -2. For manual releases: bump `version` in `package.json`, add a `CHANGELOG.md` entry, commit, `git tag vX.Y.Z && git push origin vX.Y.Z`. -3. On every release, refresh the **Last verified** date and matrix in `README.md` after smoke-testing: registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds. +2. Avoid manual releases: release-please anchors on the latest GitHub **Release**, not the latest tag, so a bare tag is invisible to it (`AGENTS.md`, "Releasing"). If one is unavoidable, bump `version` in `package.json`, commit, tag, push the tag, and create the GitHub Release in the same step (`gh release create vX.Y.Z --generate-notes`). Never touch `CHANGELOG.md`: release-please owns it. +3. On every release, refresh the **Verified on DeepSeek Harness** line and its matrix under "Compatibility & Verification" in both READMEs after smoke-testing: registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds. ## Docs diff --git a/README.md b/README.md index 2e36161d..14f86e29 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ The package is `dsh-opencode-patch` under every name it is used by: the dependen // ~/.dsh/profiles/web/package.json { "dependencies": { - "dsh-opencode-patch": "^0.14.0", + "dsh-opencode-patch": "^1.8.0", }, "dsh": { "profile": { @@ -161,13 +161,13 @@ Zen's provider-level SDK is `@ai-sdk/openai-compatible`. models.dev names a **di | models.dev `provider.npm` | models | protocol | served from | | :-- | --: | :-- | :-- | -| _(absent)_ | 23 | OpenAI Chat Completions | the route you configured | +| _(absent)_ | 24 | OpenAI Chat Completions | the route you configured | | `@ai-sdk/openai` | 31 | OpenAI Responses | `opencode-responses` | | `@ai-sdk/anthropic` | 17 | Anthropic Messages | `opencode-anthropic` | | `@ai-sdk/google` | 8 | Google Generative AI | `opencode-google` | | `@ai-sdk/mistral` | 1 | _(a dialect this plugin does not serve)_ | **not offered** | -Counts are the **80** active `opencode` models in `models.opencode.ai` as of 2026-10-23 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 80 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. +Counts are the **81** active `opencode` models in `models.opencode.ai` as of 2026-11-03 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 81 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim -- --write` (without `--write` it only checks). The patch **keeps those two routes out of both the model picker and _Settings → Models_**. Three properties hold today, and one does not yet: @@ -223,7 +223,7 @@ Merging is **additive**: adapter rows are kept, canonical rows are appended when Two things follow that you will notice in use. **Cold start is never empty** — the bundled shim answers before the first refresh, so the picker has models immediately. And **a model the catalog has no row for still appears**, with `—` where its rate would be: never a guess, and never the previous model's name. -Both generated files are checked in CI (`catalog:shim`, `limits:shim`); they exit non-zero when the vendor's data moved, which is the only way to notice the vendor's registry changing under you. +Both generated files are checked in CI (`catalog:shim`, `limits:shim`); they exit non-zero when the vendor's data moved, which is the only way to notice the vendor's registry changing under you. A pull request fails on that only when it changes the code that generates the file; otherwise CI warns, and a nightly run proposes the refresh as a pull request of its own. → Protocol routing, the merge and the UI in technical detail: [`docs/protocol-routing-and-merge.md`](./docs/protocol-routing-and-merge.md) · [中文](./docs/protocol-routing-and-merge.md) @@ -251,7 +251,7 @@ Nine controls in four sections — the decisions a user actually makes. Everythi | **Models & Free Tier** | Enrich model metadata from the OpenCode catalog | `on` | Fills catalog context limits, modalities and thinking levels into model info, and lets the gateway `/models` listing carry the full catalog. Does **not** add models to the picker — the picker shows declared models only; use the card's **Check catalog** row to declare more | | **Models & Free Tier** | Inject Core Tools | `on` | Adds the `read` + `bash` schemas free-tier `/responses` bodies require | | **Quota Meter** | Enable Composer Usage Meter | `on` | Mounts the live meter in the composer dock — the Go quota ring, or the Zen session spend | -| **Account & Endpoint** | API Endpoint | `auto` | Which endpoint family requests go to: **Automatic** (follow the catalog) · **Legacy** (`zen/v1`) · **Modern** (`inference/…`) | +| **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** | | **Account & Endpoint** | Read the OpenCode installation's key | `off` | Lets the plugin read the credential OpenCode itself stored, as the LAST resort — off by default | @@ -259,7 +259,7 @@ Nine controls in four sections — the decisions a user actually makes. Everythi Two questions decide where a request goes, and the **Account & Endpoint** section is where you answer them. -**Which endpoint family.** The gateway answers on two families — the older `/zen/v1` and the newer `inference` family, whose base is per protocol (`…/inference/openai/v1`, `…/inference/anthropic/v1`, `…/inference/google/v1beta`) — and they are entitled **separately**: an account that works on one is not guaranteed to work on the other. `auto` follows the gateway's own catalog, which today names the newer family, so a catalog move carries you along; `legacy` and `modern` pin one, which is what you want when your account only works on the older one, or when you are checking that the newer one does. +**Which endpoint family.** The gateway answers on two families — the older `/zen/v1` (`/zen/go/v1` on Go), one base for every protocol, and the newer per-protocol `inference` family (`/inference/openai/v1`, `/inference/anthropic/v1`, `/inference/google/v1beta`, with `/inference/go/…` on Go) — and they are entitled **separately**: an account that works on one is not guaranteed to work on the other. `modern` sends each protocol to its own inference path; `auto`, the default, is an alias of `modern`; `legacy` pins the older family, which is what you want when your account only works there. **Which credential wins.** `keySource` orders the sources the plugin can see — `auto` prefers a key you declared, then one captured from a live request, then the declared reference; `request` promotes a captured key first; `configured` does the reverse. Below it, `readOpencodeStore` lets the plugin read the credential OpenCode itself stored, as the **last** resort only. Both are `off`/`auto` by default, so nothing changes until you say so. @@ -274,7 +274,7 @@ Go usage Healthy ● 5 hours 0% used · resets in 4h 58m 100% left ● Weekly 28% used · resets in 3d 2h 72% left ● Monthly 14% used · resets Nov 7, 8:55 AM 86% left -Monthly allowance mimo-v2.6-pro · Go $60 · Plus $120 ⓘ +Monthly allowance mimo-v2.6-flash · Go $60 · Plus $120 ⓘ ``` **This build carries the API key path only.** There is no sign-in row and no device flow: a credential comes from the deployment's configuration, from a key captured on a live request, or — when `readOpencodeStore` is on — from the OpenCode installation's own store. The Console login (device flow, session store, credential pool) lives on the `v2` line and is deliberately not in `main`, so nothing here depends on a service the host may not mount. @@ -371,7 +371,7 @@ These exist in the schema but render no control — each is a literal, a marker, | `userAgent` | empty (= canonical CLI UA) | Literal override; the default is what the gateway expects | | `originClient` | `cli` | Literal `x-opencode-client` value | | `sessionIdEnv` | `OPENCODE_SESSION_ID` | Names an env var only a caller-supplied session id uses | -| `freeModelMarker` | `free` | Model-id substring; `*` forces the fallback, `''` disables it | +| `freeModelMarker` | `free` | Model-id substring; `*` forces the fallback; a blank value inherits the default; catalog-free models always qualify | | `usageBaseURL` | `https://opencode.ai/zen/go/v1` | Endpoint override; auto-discovered from the composition | | `credentialOrg` | — | The org id an account credential belongs to, when the deployment supplies one out of band | | `debug` / `debugFile` | `false` / — | Diagnostic JSONL logging, not a behaviour anyone tunes in the UI | @@ -389,7 +389,6 @@ These exist in the schema but render no control — each is a literal, a marker, - opencode-anthropic gatewayUrls: - opencode.ai/zen - hiddenModels: [] # ids withheld from every listing sessionIdEnv: "OPENCODE_SESSION_ID" freeModelMarker: "free" usageBaseURL: "https://opencode.ai/zen/go/v1" @@ -429,24 +428,24 @@ These exist in the schema but render no control — each is a literal, a marker, **Verified on DeepSeek Harness `0.2.1` — both the `alpha` and `rc` channels (Node 24+)** -The harness publishes two **channels**, `alpha` and `rc`, and they are parallel tracks rather than a version ordering: semver sorts `0.2.1-rc.2` _above_ `0.2.1-alpha.5`. Both channels' latest is **`0.2.1`**, and that line is this plugin's target — it is tested against every published `0.2.1` build in both channels, and the `@deepseek-ai/dsh-typert-protocol` peer range says so exactly: +The harness publishes two **channels**, `alpha` and `rc`, and they are parallel tracks rather than a version ordering: semver sorts `0.2.1-rc.2` _above_ `0.2.1-alpha.5`. The plugin targets two lines on both channels: **`0.2.1`**, the `rc` channel's latest, and the **`0.2.2`** prereleases the `alpha` channel now carries (the test install runs `0.2.2-alpha.2`). The `@deepseek-ai/dsh-typert-protocol` peer range says so exactly: ``` ->=0.2.1-alpha.1 <0.2.2 +>=0.2.1-alpha.1 <0.3.0 || >=0.2.2-0 <0.3.0 ``` -One range covers the whole line on both channels: its lower bound (`alpha.1`) sorts below every `rc` of the same line, so rc needs no separate entry. A plain `<0.2.2` would **not** work — semver excludes prereleases unless a comparator in the same `major.minor.patch` tuple carries one, which is how a range written against one channel silently excludes the other. +The first clause covers the `0.2.1` line on both channels: its lower bound (`alpha.1`) sorts below every `rc` of the same line, so rc needs no separate entry. The second is **not** redundant — semver excludes prereleases unless a comparator in the same `major.minor.patch` tuple carries one, so `<0.3.0` alone would silently drop every `0.2.2-*` build, alpha and rc alike. Both clauses stop at `0.3.0`, which leaves the next line a separate decision rather than a silent widening. | Surface | Target | | :-- | :-- | -| **Harness line** | **`0.2.1`**, `alpha` and `rc` channels alike (peer: `>=0.2.1-alpha.1 <0.2.2`) | +| **Harness line** | **`0.2.1`** and the **`0.2.2`** prereleases, `alpha` and `rc` channels alike (peer: the range above) | | **Plugin package** | [`dsh-opencode-patch`](https://www.npmjs.com/package/dsh-opencode-patch) | | **Host profile** | DSH Web profile (`patchReload: live`) | | **Routes claimed** | `opencode`, `opencode-go`, `opencode-responses`, `opencode-anthropic` | | **Gateways** | `opencode.ai/zen/v1` (`/responses`, `/chat/completions`, `/messages`, `:streamGenerateContent`), `zen/go/v1` (`/chat/completions`) | | **Catalog** | Generated from `models.opencode.ai/api.json`; retirement follows the registry's own `status: "deprecated"` flag, with no hand-maintained id list | | **Upstream pi** | **Not bundled.** The bridge loads whatever `@earendil-works/pi-ai` the harness installed, so a harness bump never skews a pinned copy | -| **Verification gate** | `vp check` clean, **961** deterministic tests green (unit + offline gateway mock), full schema validation, consumer install + load ([`scripts/check.ts`](./scripts/check.ts)) | +| **Verification gate** | `vp check` clean, the deterministic unit and offline gateway-mock suites green, full schema validation, consumer install + load ([`scripts/check.ts`](./scripts/check.ts)) | --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 34224590..c34a5ccb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,7 +37,7 @@ **默认状态。** 把 DSH 直接指向 OpenCode、不装任何插件,模型是**完全不能用**的——不是某一种形状不能用,而是全部: -- **请求不会被识别为"编码 agent"发出的。** DSH 发的是自己的 `User-Agent`,不带 `x-opencode-client`、`x-opencode-project`,也不带 `x-opencode-session`;网关看到的就是一次匿名 SDK 调用:免费层模型返回 `403 FreeTierError`,流量还要受厂商对"不表明身份的客户端"所设的滥用规则约束。 +- **请求不会被识别为"编码 agent"发出的。** DSH 发的是自己的 `User-Agent`,不带 `x-opencode-client`、`x-opencode-project`,也不带 `x-opencode-session`;网关看到的就是一次匿名 SDK 调用,流量要受厂商对"不表明身份的客户端"所设的滥用规则约束。**免费层是另一回事,原因也不在这里**:把这些请求头全部恢复、换上 CLI 的 `User-Agent`、带上有效的会话 id 以及回退注入的 `read`/`bash` 工具定义之后,在 OpenCode 之外调用 Zen 的 `-free` 模型仍然返回 `403 FreeTierError`,而同一个请求换成付费模型返回 `200`。这些 id 依然会列出——拒绝本身会说明原因,而把厂商可能仍在服务的模型藏起来是更糟的失败——补丁恢复的请求头服务于付费路由以及厂商声明的客户端规则。 - **没有会话 id 的一轮会被拒、或失去关联。** 每一轮都必须带一个稳定的 `ses_…` 形状的 id,而 DSH 在子代理、后台评估以及 **Auto Review** 这类模式下会完全省略 `sessionId`。 - **免费层的 `/responses` 请求体缺少 `read` 与 `bash` 会被拒**,而 DSH 是刻意不发这两个工具的。 - **目录很单薄。** 裸 `/models` 列表只有 id,几乎没有别的信息,所以选择器里是一堆没有名字、没有价格、没有上下文窗口的行。 @@ -92,7 +92,7 @@ npm install dsh-opencode-patch // ~/.dsh/profiles/web/package.json { "dependencies": { - "dsh-opencode-patch": "^0.12.0", + "dsh-opencode-patch": "^1.8.0", }, "dsh": { "profile": { @@ -115,7 +115,7 @@ npm install dsh-opencode-patch | 没有补丁时 | 使用 `dsh-opencode-patch` 后 | | :-- | :-- | -| Zen 免费模型报 `403 FreeTierError` | 自动恢复**网关来源头与工具回退** | +| Zen 免费模型报 `403 FreeTierError` | **照常列出,由网关用自己的话拒绝。** 请求头与工具回退会为付费路由恢复,但它们不是这个错误的原因——在全部就位的情况下实测,Zen 的 `-free` id 仍返回 `403`,而付费模型返回 `200`;拒绝来自控制台背后的服务,而不是请求携带的任何内容。所以这些 id 留在选择器里,拒绝以 `400 free_tier_restricted` 呈现,而不是变成一行被隐藏的模型 | | 一条路由只能说一种形状——基础问题解决后,其余模型返回 `500` | **逐模型路由**——按目录里每个模型自己声明的 SDK 派发到对应端点 | | 会话 ID 被拒并返回 `400 MissingSessionID` | **确定性的 `ses_…` 会话哈希**与跨轮次亲和 | | 子代理丢失对话上下文 | **父会话跟踪**(`x-opencode-parent-session-id`、`x-parent-session-id`) | @@ -159,13 +159,13 @@ Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在 | models.dev `provider.npm` | 模型数 | 协议 | 服务自 | | :-- | --: | :-- | :-- | -| _(缺失)_ | 27 | OpenAI Chat Completions | 你配置的路由 | +| _(缺失)_ | 24 | OpenAI Chat Completions | 你配置的路由 | | `@ai-sdk/openai` | 31 | OpenAI Responses | `opencode-responses` | | `@ai-sdk/anthropic` | 17 | Anthropic Messages | `opencode-anthropic` | | `@ai-sdk/google` | 8 | Google Generative AI | `opencode-google` | | `@ai-sdk/mistral` | 1 | _(本插件不服务的方言)_ | **不提供** | -数量为 `models.opencode.ai` 中 `opencode` 的 80 个**在用**模型,统计于 2026-10-23 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 80 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 +数量为 `models.opencode.ai` 中 `opencode` 的 81 个**在用**模型,统计于 2026-11-03 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 81 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim -- --write` 重新生成该文件(不加 `--write` 时只做检查)。 插件把这两条内部路由同时挡在模型选择器和 _Settings → Models_ 之外。**三条性质今天就成立,还有一条尚未成立:** @@ -202,7 +202,7 @@ opencode-responses: | **交互式多轮对话** | 通过每会话稳定的 `ses_…` id 提供 KV 提示缓存亲和 | | **子代理与分叉** | 子、父会话都参与哈希;谱系由父级请求头携带 | | **Agent 团队** | 跨编排轮次保留共享工作区的归属信息 | -| **实验性 Auto Review** | 即使后台审计调用省略 `sessionId`,仍会得到确定性的轮次状态、请求头与工具回退 | +| **实验性 Auto Review** | 即使后台审计调用省略 `sessionId`,仍会得到确定性的轮次状态、请求头与工具回退——而且它们的路由取自会话,所以会话正在用哪个模型,它们就跑哪个模型 | --- @@ -217,11 +217,11 @@ opencode-responses: | 网关的 `/models` | **这个账号**实际能用什么 | | 发现装饰 | `discoverModels` 该回答什么 | -合并是**增量式**的:适配器给的行保留,规范行只在缺失时追加,只有当按 provider 记录的退役名单点名某个模型时才省略它——那是事实,不是猜测。显示名与价格取自目录(`models.opencode.ai`),因为网关列表只给 id,别的几乎没有。 +合并是**增量式**的:适配器给的行保留,规范行只在缺失时追加,只有当目录(registry)把某个模型标为 `status: "deprecated"` 时才省略它——那是事实,不是猜测。显示名与价格取自目录(`models.opencode.ai`),因为网关列表只给 id,别的几乎没有。 有两点你在使用中会注意到。**冷启动永远不为空**——内置 shim 在第一次刷新前就能回答,选择器立刻有模型可选。而且**目录里没有条目的模型照样出现**,费率位置显示 `—`:既不猜,也不会退回去显示上一个模型的名字。 -两个生成文件都在 CI 里校验(`catalog:shim`、`limits:shim`);厂商数据变动时它们以非零码退出——这是唯一能发现厂商注册表在你背后变了的办法。 +两个生成文件都在 CI 里校验(`catalog:shim`、`limits:shim`);厂商数据变动时它们以非零码退出——这是唯一能发现厂商注册表在你背后变了的办法。只有当拉取请求改动了生成该文件的代码时,CI 才会因此失败;否则只给出警告,由每晚的任务以单独的拉取请求提出刷新。 → 协议路由、合并与界面的技术细节: [`docs/protocol-routing-and-merge.md`](./docs/protocol-routing-and-merge.md) · [English](./docs/protocol-routing-and-merge.md) @@ -249,7 +249,7 @@ opencode-responses: | **模型与免费额度** | 用 OpenCode 官方目录补全模型信息(默认开启) | `on` | 把目录的上下文上限、模态与思考档位补进模型信息,并让网关 `/models` 列表带上完整目录。**不会**往选择器里添加模型——选择器只显示已声明的;要添加请用卡片的**检查目录** | | **模型与免费额度** | 自动补全核心工具(默认开启) | `on` | 补上免费层 `/responses` 请求体所需的 `read` + `bash` schema | | **配额计量** | 开启输入区用量计量(默认开启) | `on` | 在输入框停靠栏挂载实时计量表——Go 额度环,或 Zen 会话消耗 | -| **账户与端点** | API 端点 | `auto` | 请求发往哪个端点家族:**自动**(跟随目录) · **旧端点**(`zen/v1`) · **新端点**(`inference/…`) | +| **账户与端点** | API 端点 | `auto` | 请求发往哪个端点家族:**自动**(同新端点) · **旧端点**(`zen/v1`、`zen/go/v1`) · **新端点**(按协议的 `inference/{openai,anthropic,google}/…`,Go 为 `inference/go/…`) | | **账户与端点** | 凭据来源 | `auto` | 多个密钥同时可用时以哪个为准:**自动** · **优先实时请求** · **优先已声明密钥** | | **账户与端点** | 读取 OpenCode 安装版的密钥(默认关闭) | `off` | 允许本插件读取 OpenCode 自己保存的凭据,且仅作最后手段——默认关闭 | @@ -257,7 +257,7 @@ opencode-responses: 一个请求发往哪里由两个问题决定,答案就在**账户与端点**分区里。 -**发往哪个端点家族。** 网关在两套端点上都应答——较早的 `/zen/v1` 与较新的 `inference` 家族(其 base 按协议区分:`…/inference/openai/v1`、`…/inference/anthropic/v1`、`…/inference/google/v1beta`)——而两者的授权是**彼此独立**的:在一边可用的账户不保证在另一边可用。`auto` 会跟随网关自己的目录,目前它指向较新的一套,因此目录迁移会带着你一起走;`legacy` 与 `modern` 则固定其一,适用于账户只在较早一套上可用,或你想确认较新的一套是否可用的情况。 +**发往哪个端点家族。** 网关在两套端点上都应答——较早的 `/zen/v1`(Go 为 `/zen/go/v1`),所有协议共用一个基址;以及较新的按协议划分的 `inference` 家族(`/inference/openai/v1`、`/inference/anthropic/v1`、`/inference/google/v1beta`,Go 为 `/inference/go/…`)——而两者的授权是**彼此独立**的:在一边可用的账户不保证在另一边可用。`modern` 将每个协议发往各自的 inference 路径;`auto`(默认值)是 `modern` 的别名;`legacy` 固定使用较早的一套,适用于账户只在那里可用的情况。 **以哪个凭据为准。** `keySource` 决定插件可见的多个凭据源的优先级——`auto` 优先使用你声明过的密钥,其次是从实时请求中捕获的密钥,最后是声明的凭据引用;`request` 把捕获到的密钥提到最前;`configured` 则相反。其下的 `readOpencodeStore` 允许插件读取 OpenCode 自己保存的凭据,且**仅作最后手段**。两者默认分别为 `auto` 与关闭,因此在你说要改之前一切照旧。 @@ -270,9 +270,13 @@ Go 用量 状态正常 ● 5 小时 0% 已用 · 4小时58分钟后重置 100% 剩余 ● 每周 28% 已用 · 3天2小时后重置 72% 剩余 ● 每月 14% 已用 · 11月7日8点55分重置 86% 剩余 -月度额度 mimo-v2.6-pro · Go $60 · Plus $120 ⓘ +月度额度 mimo-v2.6-flash · Go $60 · Plus $120 ⓘ ``` +**这个版本只带 API 密钥路径。** 没有登录行,也没有设备流:凭据来自部署的配置、来自从实际请求中捕获的密钥,或者——在开启 `readOpencodeStore` 时——来自 OpenCode 安装自身的存储。Console 登录(设备流、会话存储、凭据池)在 `v2` 线上,刻意不放进 `main`,因此这里不依赖宿主可能没有挂载的服务。 + +**如果更新后卡片看起来还是上一版,说明宿主进程没有重启。** 宿主包只在进程启动时读取一次,所以浏览器刷新仍在运行上一版的 `lib/index.mjs`;请停止并重启 `dsh web`。这一行是最快的判断办法,因为它和其他所有改动在同一个版本里新加入。 + 「剩余」是百分比,不是美元数:`/usage` 每个窗口只给一个百分比、完全没有余额接口,而套餐档位也无法探测——任何美元余额都会是猜的。这一点由 ⓘ 说明。读不到配额时它明说读不到,并且不打印任何数字,而不是打一排会被当成真实读数的 `0%`。 ### 输入框停靠栏计量表 @@ -361,7 +365,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 | `userAgent` | 空(= 规范 CLI UA) | 字面覆盖;默认值就是网关所期望的值 | | `originClient` | `cli` | `x-opencode-client` 的字面值 | | `sessionIdEnv` | `OPENCODE_SESSION_ID` | 指定一个仅当调用方提供会话 id 时才使用的环境变量名 | -| `freeModelMarker` | `free` | 模型 id 子串;`*` 强制启用回退,`''` 关闭回退 | +| `freeModelMarker` | `free` | 模型 id 子串;`*` 强制启用回退;留空则沿用默认值;目录标记为免费的模型始终适用 | | `usageBaseURL` | `https://opencode.ai/zen/go/v1` | 端点覆盖;从组合中自动发现 | | `credentialOrg` | — | 账户凭据所属的组织 id,在部署从带外提供一个时使用 | | `debug` / `debugFile` | `false` / — | 诊断用 JSONL 日志,不是谁会在 UI 里调节的行为 | @@ -404,7 +408,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 | 症状 | 可能原因 | 解决方法 | | :-- | :-- | :-- | -| 免费模型报 `403 FreeTierError` | 网关请求头被剥离,或缺少工具定义 | 保持**恢复 User-Agent**、**注入客户端来源头**与**自动补全核心工具**开启 | +| 免费模型报 `403 FreeTierError` | 网关的门槛不在请求里:用真实账号、它的会话令牌、它的当前组织以及官方客户端发送的全部请求头实测,免费层模型仍返回 `403` | 上面三个注入开关(**恢复 User-Agent**、**注入客户端来源头**、**自动补全核心工具**)仍值得保持开启——它们让流量可被识别,并恢复工具定义——但单靠它们打不开免费层。付费模型配上密钥即可使用;排除了什么、如何排除,见 [docs/opencode-1.19-api.md](./docs/opencode-1.19-api.md) | | `400 MissingSessionID` | 未附带会话请求头 | 确认 `dsh-opencode-patch` 已列入 profile 的 `dsh.profile.bundles` | | Auto Review 报 `TRANSPORT: Connection error` | 更新后宿主进程未重启 | 停止并重启 `dsh web`,让新的 `lib/index.mjs` 加载 | | Go 模型始终不显示额度环 | 解析不到 Go 凭据,或当前激活的网关不是 `opencode-go` | 在 DSH Credentials 中存储 `OPENCODE_GO_API_KEY`,并经 `opencode-go` 路由 | @@ -418,24 +422,24 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 **已在 DeepSeek Harness `0.2.1` 上验证 —— `alpha` 与 `rc` 两个通道(Node 24+)** -Harness 按 `alpha` 与 `rc` 两个**通道**发布,它们是平行的发布线而非版本先后:semver 中 `0.2.1-rc.2` 排序在 `0.2.1-alpha.5` **之上**。两个通道的最新版本都是 **`0.2.1`**,本插件针对这条线——对两个通道中每一个已发布的 `0.2.1` 构建都做过验证,`@deepseek-ai/dsh-typert-protocol` 的 peer 范围正是这么写的: +Harness 按 `alpha` 与 `rc` 两个**通道**发布,它们是平行的发布线而非版本先后:semver 中 `0.2.1-rc.2` 排序在 `0.2.1-alpha.5` **之上**。本插件在两个通道上都针对两条线:**`0.2.1`**(`rc` 通道的最新版本),以及 `alpha` 通道目前发布的 **`0.2.2`** 预发布版本(测试安装运行的是 `0.2.2-alpha.2`)。`@deepseek-ai/dsh-typert-protocol` 的 peer 范围正是这么写的: ``` ->=0.2.1-alpha.1 <0.2.2 +>=0.2.1-alpha.1 <0.3.0 || >=0.2.2-0 <0.3.0 ``` -一个范围就覆盖了两个通道的整条线:下界 `alpha.1` 排序低于同线的所有 `rc`,因此无需单独枚举 rc。反之,只写 `<0.2.2` **不行**——除非同一 `major.minor.patch` 的某个比较符带有预发布号,semver 会把预发布版本排除在外,这正是"按一个通道写的范围会悄悄排除另一个通道"的原因。 +第一个子句覆盖两个通道上的 `0.2.1` 整条线:下界 `alpha.1` 排序低于同线的所有 `rc`,因此无需单独枚举 rc。第二个子句**并不多余**——除非同一 `major.minor.patch` 的某个比较符带有预发布号,semver 会把预发布版本排除在外,所以只写 `<0.3.0` 会悄悄丢掉所有 `0.2.2-*` 构建,alpha 与 rc 都一样。两个子句都止于 `0.3.0`,下一条线因此是一个单独的决定,而不是悄悄放宽。 | 层面 | 目标 | | :-- | :-- | -| **Harness 版本线** | **`0.2.1`**,`alpha` 与 `rc` 通道均可(peer:`>=0.2.1-alpha.1 <0.2.2`) | +| **Harness 版本线** | **`0.2.1`** 与 **`0.2.2`** 预发布版本,`alpha` 与 `rc` 通道均可(peer:见上方范围) | | **插件包** | [`dsh-opencode-patch`](https://www.npmjs.com/package/dsh-opencode-patch) | | **宿主 profile** | DSH Web profile(`patchReload: live`) | | **声明的路由** | `opencode`、`opencode-go`、`opencode-responses`、`opencode-anthropic` | | **网关** | `opencode.ai/zen/v1`(`/responses`、`/chat/completions`、`/messages`、`:streamGenerateContent`)、`zen/go/v1`(`/chat/completions`) | | **模型目录** | 由 `models.opencode.ai/api.json` 生成;下线判定直接跟随目录自身的 `status: "deprecated"` 标记,不维护任何手工 id 列表 | | **上游 pi** | **不随包携带。** 桥接在运行时加载 harness 已安装的那份 `@earendil-works/pi-ai`,因此 harness 升级不会与本插件锁定的副本产生版本错位 | -| **验证关卡** | `vp check` 干净、**961** 个确定性测试全绿、完整 schema 校验、消费者安装 + 加载([`scripts/check.ts`](./scripts/check.ts)) | +| **验证关卡** | `vp check` 干净、确定性单元与离线网关 mock 套件全绿、完整 schema 校验、消费者安装 + 加载([`scripts/check.ts`](./scripts/check.ts)) | --- diff --git a/cordis.patch.yml b/cordis.patch.yml index 75beab27..0b8165eb 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -20,8 +20,12 @@ # model discovery; off leaves both raw (default true). # injectCoreTools: boolean auto-inject read & bash tool schemas on free models (default true). # usageEnabled: boolean enable host-side OpenCode Go usage querying (default true). -# showUsagePrice: boolean show session spend + active model rate in the meter (default true). # keySource: string which credential source wins: auto | request | configured (default 'auto'). +# readOpencodeStore: boolean read the credential an OpenCode installation already holds, as the LAST +# resort after every declared one (default false). +# endpoint: string endpoint family: auto | modern | legacy (default 'auto', an alias of +# modern). modern = the per-protocol inference family; legacy = one base +# per plane. # # Config-only overrides — the card deliberately hides these, because each is a # literal, a marker, or a reference whose default fits every documented setup. @@ -33,12 +37,21 @@ # userAgent: string optional custom User-Agent override (default empty = use OpenCode CLI UA). # originClient: string x-opencode-client value (default 'cli'). # sessionIdEnv: string env var carrying a caller-supplied session id (default 'OPENCODE_SESSION_ID'). -# freeModelMarker: string model-id substring getting the core-tool fallback ('*' = all, '' = none; default 'free'). +# freeModelMarker: string model-id substring getting the core-tool fallback ('*' = all; a blank +# value inherits the default 'free'; models the catalog marks free always +# qualify). # gatewayUrls: [string] URL substrings marking OpenCode gateway traffic (default both # families: ['opencode.ai/zen', 'opencode.ai/inference']). # usageBaseURL: string OpenCode Go gateway base URL (default 'https://opencode.ai/zen/go/v1'). -# debug: true|false log every streamed call that receives the header. -# debugFile: path optional append target for stream debug JSONL. +# credentialOrg: string org id (wrk_...) sent with an account credential that came from elsewhere. +# maxContextWindow: number optional ceiling clamping model context windows (e.g. 131072). +# modelOverrides: object per-model overrides for contextWindow, maxTokens and metadata (default {}). +# modelRoutes: object per-model wire routing: { : { api, baseURL } } (default {}). +# internalRoutes: boolean mount the internal per-protocol routes; off for a deployment that +# declares its own providers for the other protocols (default true). +# debug: true|false log every streamed call that receives the header to the console. +# debugFile: path optional JSONL file for the request and stream debug entries; naming +# it writes the file whether or not debug is on. - insert: - id: dsh-opencode-patch name: "dsh-opencode-patch" @@ -58,5 +71,4 @@ injectCoreTools: true enrichModels: true usageEnabled: true - showUsagePrice: true debug: false diff --git a/docs/README.md b/docs/README.md index d8e2d6d4..f14b8b0c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Documentation -One document per subject. Everything here is engineering material and written in English, except the meter's guide, which is user-facing and therefore exists in both languages, the same rule the READMEs follow. +One document per subject. Everything here is engineering material and written in English, except the meter's guide and the plugin page, which are user-facing and therefore exist in both languages, the same rule the READMEs follow. | Document | Subject | Language | | :-- | :-- | :-- | @@ -9,8 +9,15 @@ One document per subject. Everything here is engineering material and written in | [`protocol-routing-and-merge.md`](./protocol-routing-and-merge.md) | the routing **implementation**: why routing exists, how the route is chosen, the catalog merge, and where the code lives | English | | [`engineering-notes.md`](./engineering-notes.md) | vendor findings, and the rules that came out of real failures | English | | [`credentials.md`](./credentials.md) | the credential chain: the deployment's own reference, both OAuth logins, refresh semantics, and the zero-configuration guarantee it must not break | English | +| [`testing.md`](./testing.md) | the test system: the layers, the fixture harness, the scripts' pure halves | English | +| [`upstream-findings.md`](./upstream-findings.md) | what upstream OpenCode does that this plugin tracks, what the host would have to change, and what was verified against upstream source | English | +| [`opencode-1.19-api.md`](./opencode-1.19-api.md) | the API the current OpenCode CLI talks to instead of this plugin's, and what a migration would change | English | +| [`opencode-parity-plan.md`](./opencode-parity-plan.md) | the parity plan with the OpenCode CLI: protocol, credential, session and catalog fidelity | English | +| [`improvement-plan.md`](./improvement-plan.md) | the plan of record: what the audit found, what the host supports, and the phased refactor, with status | English | | [`quota-meter.md`](./quota-meter.md) | the composer meter, state by state: what it shows, what each row answers, and where the numbers come from | English | | [`quota-meter.zh-CN.md`](./quota-meter.zh-CN.md) | 同一份说明的中文版 | 中文 | +| [`detail.md`](./detail.md) | the plugin page the host renders for this package (`meta.detail`) | English | +| [`detail.zh-CN.md`](./detail.zh-CN.md) | 插件页的中文版 | 中文 | **Specification versus implementation is the one split worth knowing.** `redirect-planes.md` says what the code **must** do; `protocol-routing-and-merge.md` says what it **does**; the guards in the former are what holds the two together. When they disagree, one of them is wrong. That is the point of keeping both. diff --git a/docs/deep-dive.md b/docs/deep-dive.md index 9affe5c1..86a7bfe6 100644 --- a/docs/deep-dive.md +++ b/docs/deep-dive.md @@ -168,9 +168,9 @@ How does this compare to Duskriver's [`dsh-opencode-go`](https://www.npmjs.com/p OpenCode's gateway `GET …/models` endpoints frequently return a truncated subset: no display names, context windows, max tokens or input modalities. The plugin ships a **stale-while-revalidate** catalog for both planes: -1. Dual bundled shims (zero latency, offline): `OPENCODE_GO_CATALOG` carries every active Go subscription model with per-million-token rates, so session pricing works before the first refresh; `OPENCODE_ZEN_CATALOG` carries the flagships (`claude-sonnet-4-5`, `claude-opus-4-7`, `gpt-5.4`, `gemini-3.8-flash`, `qwen3.8-max`, `kimi-k3`). The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file, and CI fails when the shim is stale. Retired models are excluded so a failed refresh can never resurrect a row the gateway no longer serves: the three Zen-route Muse Spark 1.2 ids are suppressed, while the paid Go 1.2 contributor entry stays (the CLI still lists it). Startup is instant: no cold-start delay, blocking network calls, or airplane-mode failures. +1. Dual bundled shims (zero latency, offline): `OPENCODE_GO_CATALOG` carries every active Go subscription model with per-million-token rates, so session pricing works before the first refresh; `OPENCODE_ZEN_CATALOG` carries the flagships (`claude-sonnet-4-5`, `claude-opus-4-7`, `gpt-5.4`, `gemini-3.8-flash`, `qwen3.8-max`, `kimi-k3`). The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file, and CI checks the shim for drift: a pull request that changes the generator (or what it loads) fails on a stale shim; any other pull request, and a push to main, warns, and the nightly run proposes the refresh. Retired models are excluded so a failed refresh can never resurrect a row the gateway no longer serves: a retired model is a row the registry marks `status: "deprecated"`, the generator drops it per plane, and, like the counts, the ids are deliberately not restated here. Startup is instant: no cold-start delay, blocking network calls, or airplane-mode failures. 2. Background revalidation: both catalogs revalidate against [`https://models.opencode.ai/api.json`](https://models.opencode.ai/api.json) — the OpenCode catalog, which today serves the byte-identical object as `models.dev/api.json` — every **60 minutes** (the OpenCode CLI's canonical cycle), merging new models, deprecations and updated limits. Errors degrade gracefully and retain the active catalog. -3. Gateway models-endpoint enrichment: `patchFetch` intercepts `GET …/models` on OpenCode routes and merges the live Go or Zen catalog: human-friendly names (`DeepSeek V4.1 Flash`, `Qwen3.8 Flash`, `Grok 4.7`, `MiMo V2.6 Pro`), verified context windows (up to 1,000,000+ tokens) and max output tokens (up to 384,000), correct input modalities (`text`, `image`), with retired Muse Spark 1.2 rows omitted. +3. Gateway models-endpoint enrichment: `patchFetch` intercepts `GET …/models` on OpenCode routes and merges the live Go or Zen catalog: human-friendly names (`DeepSeek V4.1 Flash`, `Qwen3.8 Flash`, `Grok 4.7`, `MiMo V2.6 Pro`), the catalog's context windows and max output tokens, correct input modalities (`text`, `image`), with registry-deprecated rows omitted. 4. Settings “Fetch Available Models” decoration: DSH asks the route's own adapter first, and for an installed `opencode` route `llm-pi-ai` answers from its packaged catalog without calling the gateway. The plugin therefore decorates the hosted discovery result: adapter rows and order are preserved, their metadata enriched from the catalog, provider-retired rows removed, and **models whose protocol DSH cannot speak dropped** (offering one could only fail). It **does not append** catalog rows the adapter did not report: an earlier revision did, and that is how a deployment that had declared four models saw eighty-six in Settings. This is candidate metadata for the settings surface; it never rewrites saved route configuration. Widening a deployment's list is a separate, explicit action — the card's **Check catalog** row, which shows what the catalog offers and declares it only when asked. 5. Native model discovery registration: on the host runtime the plugin also registers with `ctx.llm.registerModelDiscovery` for `opencode-go` and `opencode`, which is what a _draft_ route's “discover” call answers from — not the picker, which reads the declared list. The gateway-listing enrichment, this registration, the discovery decoration and the model-info decoration all sit behind the **Enrich model metadata from models.dev** switch; the adapter decoration and the Responses-route hiding do not. diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 5dd94e11..efe8f783 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -92,9 +92,9 @@ All three were listed on the same route, which declares `api: openai-completions So the fix belongs here rather than in anyone's profile: the routes for the other protocols have to come from the plugin, the way the completions route already does. Until they do, a model that names an SDK other than completions is offered on a route that cannot serve it, and the answer is the `400` above. -**And the deeper half: a model the catalog does not carry cannot be routed at all.** Measured: `gemini-3.8-flash` and `claude-sonnet-4-6` are in neither shipped catalog, so `findModelSpecOn` finds no entry, `provider_npm` is `undefined`, and `undefined` is the DEFAULT — the completions shape. That default is right for a model that omits its SDK and wrong for one the catalog has never heard of, and the two are indistinguishable at that point. A model listed by hand on a completions route therefore stays there whatever protocol it actually speaks, and the gateway answers `400` — which is the whole of the reported symptom. +**And the deeper half: a model the plane's catalog does not carry cannot be routed at all.** Measured on the Go plane, where the table above was taken: neither `gemini-3.8-flash` nor `claude-sonnet-4-6` has a row in the Go catalog (both are Zen rows), so `findModelSpecOn("go", …)` finds no entry, `provider_npm` is `undefined`, and `undefined` is the DEFAULT — the completions shape. That default is right for a model that omits its SDK and wrong for one the catalog has never heard of, and the two are indistinguishable at that point. A model listed by hand on a completions route therefore stays there whatever protocol it actually speaks, and the gateway answers `400` — which is the whole of the reported symptom. -`catalog-data.ts` carries `provider_npm` only for models that override the default, so absence is meaningful for the 82 it ships and meaningless for anything else. A hand-listed model needs its protocol from somewhere: the catalog, a route that declares it, or a rule that reads it off the id. +`catalog-data.ts` carries `provider_npm` only for models that override the default, so absence is meaningful for the models it ships and meaningless for anything else. A hand-listed model needs its protocol from somewhere: the catalog, a route that declares it, or a rule that reads it off the id. **And the other half of I1: it is still OFFERED.** `models-discovery.ts` filters a route's listing with `isServableModel`, which asks the catalog for the model's SDK and treats a missing answer as servable — correctly, because absence means the completions default. For a model the catalog has never heard of, absence means the same thing by accident, so a hand-listed model is offered on a route that cannot serve it. The routing fix above moves it when it is chosen; this is the half that would stop it being offered at all, and the two belong together: I1 says a model that cannot work is not offered, and today that clause holds only for models the catalog describes. @@ -332,7 +332,7 @@ So it is a known gap with a known shape rather than an unknown one: a number in ## The count gap is closed, and nothing else restates one -With the prose totals asserted, every count in this repository is now either parsed by a test or generated by the script that owns it. Swept for the rest: no other document states a catalog number, and `deep-dive.md` avoids the problem the way it should — _"The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file, and CI fails when the shim is stale."_ That is the better pattern, and it is why that file was never wrong about a count while four others were. +With the prose totals asserted, every count in this repository is now either parsed by a test or generated by the script that owns it; a test total the README once stated went the same way, dropped rather than left for nothing to check. Swept for the rest: no other document states a catalog number, and `deep-dive.md` avoids the problem the way it should — _"The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file …"_ That is the better pattern, and it is why that file was never wrong about a count while four others were. So the class has an ending rather than a fifth instance: numbers live where a test reaches them, or they are not written down. @@ -542,6 +542,18 @@ What does NOT work, stated plainly, is the other half. `prepareCall` reads the r The fix is therefore not a mount: it is a route whose adapter the plugin itself owns and whose `models` list carries the catalog's `reasoningEfforts` — the same `models` list the internal routes already use, which works precisely because `configured.length > 0` takes the `entries` from it directly and tolerates ids the installed catalog has never heard of. That means giving the model an SDK so `internalRouteFor` can redirect it, or listing it in the deployment's profile, and neither is something to invent without a measurement of which endpoint the model is actually served on. +## Replacing another plugin's adapter registration is not an API, so the plugin does not try + +`src/adapter-replacement.ts` took a provider route over from the adapter that already served it: it released the llm registry's map entry for a route pi-ai held, registered our adapter through `registerAdapter`, and put the original entry back on disposal. Nothing in `src/` ever imported it, and P1b deleted it with its test. + +It could not have been wired, and the reason is read from the host's llm runtime (`packages/llm/llm/src/index.ts`) rather than assumed: + +1. **`registerAdapter` is all-or-nothing against a private map.** `prepareRoutes` throws `DUPLICATE_ADAPTER` for any route another registration holds (`:440-444`), so the only way in was to delete that registration's entry first. +2. **Only the owner moves its routes.** A registration's routes change through its own `handle.replace`, the method `registerAdapter` returns on its handle (`:421-428`). Nothing else is an API for it. +3. **The owner's disposer deletes what it still believes it holds.** On dispose, the registration deletes every route in its `owned` set and emits `llm/adapters-updated` (`registerAdapter`'s disposer, `:411-416`). An entry deleted from outside leaves pi-ai's registration believing it still owns the route, so its next dispose or restart deletes OUR entry — the plane then answers `NO_ADAPTER` — and its next `replace` registers over us. This failure mode is read from the host's disposer and has not been run. S0's handback failed for a different reason: the prototype released its registration while its own config listener was still live (see the plan's spike record), which is why P-R1 calls its handle and then makes its listener inert in one synchronous section, with no await between them, and restarts pi-ai only after both (the plan's Teardown). + +What the plugin does instead (D13, landing in P-R1 as `src/host/route-claim.ts`): keep pi-ai from claiming `opencode` / `opencode-go` in the first place, through the config waterfall — a prepended `internal/config` listener strips the two plane keys from a copy of the config pi-ai reads — and register its own adapter under the same ids through the ordinary `registerAdapter`. On teardown it releases its own handle and restarts pi-ai, which hands the routes back. It never deletes another plugin's registration; `test/architecture.test.ts` fails the build on a direct `adapters.delete` / `set` / `clear` call (optional chaining included) anywhere in `lifecycle.ts`'s static import graph. + ## A placeholder in a string has no spelling this linter accepts `check.ts` asserts the refresh push carries the PAT by looking for the token URL as a literal, and the literal carries the workflow's own placeholders. Written plainly (`"…${GH_TOKEN}…"`), `no-template-curly-in-string` flags it; written with escapes (`"…\${GH_TOKEN}…"`), `no-useless-escape` flags every escape instead — a plain string has no spelling this oxlint accepts. A regex literal is not an escape hatch either: `prefer-includes` rejects a pattern that is a static string. The one spelling both rules accept is a template literal with `\${`: there the backslash is the documented way to write a literal placeholder, and neither rule applies. diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md new file mode 100644 index 00000000..f8e016a9 --- /dev/null +++ b/docs/improvement-plan.md @@ -0,0 +1,672 @@ +# Improvement plan + +## Context + +The result of an architecture audit of the tree at 1.4.1, and the order in which to act on it. It rests on: + +- six audits: routing, catalog, credentials, config/UI, tests/CI/docs, and the stream path; +- an adversarial review of the plan itself, then a nine-lens review (five reviewers, four investigators) in which two independent skeptics checked every finding: 78 of 82 were upheld and are folded in here; +- the host's own source and data (`deepseek-ai/deepseek-harness`, and the pi 1.1.0 catalog its rc tag pins; the alpha tag pins 1.3.0, D14'). File references into the host are relative to that repository's `packages/`. + +**Restacked onto 1.8.0 (November 2026).** The stack was cut from 1.4.1. Since then `main` moved to 1.8.0 and followed the harness onto its **alpha** track (the `0.2.2-alpha.2` cluster, cordis `4.0.6-alpha.1`, pi `1.4.0` exact), and it landed several things the plan had scheduled: the plane-aware price, free flag and refusal (#133), a working retired alias (#135), the synthetic catalogue fixtures (#138), the language-pair gate and the live-run verdicts (#140), and the scripts' pure halves (#143, #144). Each open phase merges its parent, and only P0 merges `main`. What changed in the decisions is under "Owner decisions" (D14', D22', D17's defaults, D23, D24, D25), what changed per phase is under "Restack adjustments", and what two community plugins built on the same host taught is under "Lessons from opencode2dsh". A new phase, P-R3, follows P-T3. + +**Outcome:** a plugin that only uses host extension points the host supports; one source of truth per decision; a suite that pins behaviour before it moves; and the owner's product rules below. + +**Each phase is one pull request**, and it updates this document: its row below moves to "merged", and its section is trimmed to what is left. A finding that does not survive its phase's first failing test is struck from here rather than forced. + +## Status + +Phases land as **stacked pull requests**. Each one is based on the previous phase's branch, and the first stacks on P0. Every phase is a 1.x release: there is no major (D6). + +| Phase | PR | Title | State | Restack onto 1.8.0 | +| :-- | :-- | :-- | :-- | :-- | +| P0 | #105 | suite hardening, 10 defect fixes, this plan | in review | **merged `main` 1.8.0**; catalog refreshes and the pricing fix dropped (on `main`) | +| P1a | #106 | `fix:` the purge row's refusal and the endpoint copy | in review | **merged P0**; shrunk to the purge row's `RemoteError.message`, the `auto` = `modern` copy and the `opencode-google` regression tests; the warning colour drops its token fallback; the catalog's refusal tests gain three cases `main` lacks; the rest is on `main`, and the debug gate follows D24 | +| P1b | #107 | `chore:` hygiene | in review | **merged P1a**; takes `main`'s tool-fallback plane rule (D25) and keeps only the dead Anthropic branch's deletion; its catalogue fixture and `main`'s `test/fixtures/catalogs.ts` become one module behind the one door | +| P2 | #108 | `feat:` a smaller public API | in review | pending: merges P1b; re-retires the wrapper (D9) | +| P-T1a | #109 | `build:` pin the host | in review | pending: merges P2; redone on the alpha cluster and pi 1.3.0 (D14', D22') | +| P-T1b | #110 | `test:` a harness that is the real host | in review | pending: merges P-T1a; re-measured on both cells | +| P-H | #112 | `refactor:` one way to patch a host method | in review | pending: merges P-T1b | +| P-R1 | #113 | `feat:` the plugin owns the plane routes | in review | pending: merges P-H; restates pi-ai's defaults (D17) | +| P-R2 | #114 | `fix:` the endpoint family, measured | in review | pending: merges P-R1; gains the L3-L5, L9, L10, L13 probes | +| P3 | #115 | `feat:` Settings complements pi's list | in review | pending: merges P-R2 | +| P-T2 | #116 | `test:` suite structure | in review | pending: merges P3; absorbs `main`'s tests and `docs/testing.md` | +| P-T3 | | `ci:` CI and scripts | planned | shrinks: `main` extracted the scripts' pure halves | +| P-R3 | | `fix:` honest refusals | planned | new | +| P5 | | `refactor:` installers | planned | | +| P6 | | `refactor:` the fetch patch's credential fill | planned | | +| P7 | | `refactor:` one source per table | planned | | +| P8 | | `refactor:` settings table | planned | | +| P9 | | `refactor:` remote seam | planned | | +| P10a | | `refactor:` pure module moves | planned | | +| P10b | | `fix:` catalog state | planned | | +| P11 | | `refactor:` UI on the primitives | planned | | +| P12 | | `ci:` lint warnings gating | planned | | + +## Owner decisions + +| # | Decision | +| :-- | :-- | +| D1 | A model whose reasoning is only on/off is offered as **Off / On**. Old saved Minimal/Xhigh/Max selections on such models break, and the release notes say so. It lands in P-R1, with the catalog-first precedence (D17). | +| D2 | Delete `internalRoutes`. | +| D3 | **The composer shows the user's declared models.** An absent or empty `models` list serves the plugin's servable, non-deprecated catalog for that plane. A plane with no entry at all is not registered. **Settings offers the whole catalog.** | +| D4 | Delete `resolveRoutedKey`. It was exported in dde9d6e and nothing in `src/` has ever called it. | +| D5 | **Serve every mode on the user's own route ids, with no hidden routes.** | +| D6 | **Shrink the public API in a 1.x minor.** There is no 2.0.0: nobody consumes the API. Titles are `feat:`, `fix:`, `refactor:` and so on, with no `!` and no `BREAKING CHANGE` footer. Behaviour changes are named in each PR body and in the README's "Changed in 1.x" notes. | +| D7 | Stacked pull requests: one phase per branch, each based on the previous one. | +| D8 | **The plugin never writes the user's profile on its own, and never overrides the user's configuration at dispatch.** The only write is the card's explicit purge action, which goes through `configEditor`. | +| D9 | Stop publishing `@viztor/dsh-opencode` in P2. The deprecation message already published ("Renamed to …") stays as the final one. **Stands after the restack:** `main` revived the alias and fixed its forwarder (#135, `scripts/wrapper-forwarder.ts`), so the last published alias is a working one, and P2 retires it again, deleting the forwarder and its test. | +| D10 | This document is the plan of record. Each phase PR updates its status row. | +| D11 | The adapter patch existed to merge modes. Once the plugin's provider serves every mode per model (D13), the merge is native and the patch is deleted. | +| D12 | **No upstream changes.** Everything is done inside the current host with the APIs it has. | +| D13 | **The plugin owns the `opencode`/`opencode-go` routes.** pi-ai is kept from claiming them through the config waterfall, and the plugin registers its own adapter under the same ids (F11–F14, run S0). It never deletes another plugin's registration, which the host forbids. | +| D14' | **The pinned cell runs the pi the target harness tag pins, with that tag's own patch**: today the alpha tag's pi 1.3.0 and its `@earendil-works__pi-ai@1.3.0.patch`. **The floating cell runs what the registry resolves for the published wrapper** (1.5.0 today), and its rows are version-agnostic. The patched cell stands for the harness's development tree, not for any shipped host, and says so. pi 1.4.0, which `main` pins exactly, is nobody's host and is dropped. _Replaces D14 ("target pi 1.1.0, the version the harness pins"): the alpha harness pins 1.3.0, and its wrapper's `^1.3.0` excludes 1.1.0._ | +| D15 | **Any model id saved on `opencode`/`opencode-go` is accepted and served**, including ids neither pi nor the live catalog knows. | +| D16 | **The protocol comes from the catalog, never from a guess at the model's name.** The order is: the catalog's SDK through `PROTOCOL_FOR_SDK`, then pi's own catalog entry, then the user's route-level `api`, then `openai-completions`. `protocolFromModelId` is deleted. **Stands after the restack.** pi 1.3 and later send the newest Google family's thinking levels themselves, so both P-R1's catalog-driven levels and `main`'s hard-coded bridge (#130) are no-ops for today's ids; P-R1 keeps its rule for an id the catalog marks level-only and pi's pattern misses, and #130's code goes with the module P-R1 deletes. | +| D17 | **Precedence is catalog first.** Metadata and reasoning levels come from the user's model row, then pi-ai's route-level `modelOverrides`, then the catalog, then pi, then the defaults. **The defaults are pi-ai's, restated:** a 1_000_000-token context window and 128_000 output tokens on the alpha host (they were 262_144 and 32_768). pi-ai does not export them, so a test reads them from the installed pi-ai and fails when they move, rather than letting a stale copy understate a window. | +| D18 | **Settings keeps showing the planes.** One read-only wrapper on `settings.describe` puts the plane entries back into pi-ai's displayed value (F20). The user's config is never written: the Models page writes only through `settings.mutate` path edits (`client/ui-settings-models/src/client/operations.ts:87`). | +| D19 | **Plugin settings.** _Stands after the restack: `main`'s copy says `auto` "follows the catalog", but its code agrees that `auto` is `modern`; P1a's copy wins, pinned by `endpoint-copy.test.ts`._ Delete `modelOverrides`, `maxContextWindow`, `modelRoutes` and `internalRoutes`, and the README's nonexistent `hiddenModels` (removed in P0). Keep `endpoint` (`legacy` or `modern`, with `auto` an alias of `modern`). Keep `providers`, redefined as the route ids whose turns the plugin decorates, with both planes always included. | +| D20 | **The plane adapter resolves keys by pi-ai's rule.** A named ref resolves through the credentials service, then the launch environment, then fails with `MISSING_CREDENTIAL` and sends no request. With no ref, pi's ambient key applies, plus the `readOpencodeStore` opt-in. Captured and literal keys never feed the adapter; they stay in the fetch patch's fill for an empty or placeholder wire key. | +| D21 | The keyless paid-model filter on listings is dropped (D3, D8). Without a key, dispatch fails loudly. | +| D22' | The `@deepseek-ai/*` dev dependencies are the **alpha.2 cluster** `main` already follows: `0.2.2-alpha.2`, with cordis `4.0.6-alpha.1` and its loader `1.0.7-alpha.1`, which is what `dsh@alpha` installs. They are named once, in `test/support/host-versions.ts`. The rc line survives only as the peer range `main`'s `harness-channel.test.ts` enforces for both channels. _Replaces D22 (the 0.2.1-rc.2 cluster)._ | +| D23 | **CI's freshness checks gate a pull request only on its own inputs.** A check's inputs are its script and every local module it loads, plus `src/identity.ts` for the CLI version; the generated tables never are. Vendor drift warns, and the nightly run proposes the refresh. The strict input lists in `test/freshness-scope.test.ts` are the record: `cli:version`'s grew by `scripts/vendor-version.ts` when `main` gave the check a pure half (#144), and the copy scan's generated-table skip moved into that half. | +| D24 | **The debug gate is `main`'s rule:** naming `debugFile` writes both halves of the log; `debug` governs console output only. `main` chose it (#133), and its README example (`debug: false` with a `debugFile`) relies on it. P1a adopts it, and turns its "writes nothing while debug is off" test into one that asserts the rule. | +| D25 | **The tool fallback's plane is `main`'s rule:** the request URL decides the plane, with a plane-blind lookup as the fallback (#138, `coreToolDecision`). P1b drops its route-mapped variant with no fallback. | + +## What the host supports (read from source) + +- **Routes.** + - `registerAdapter` is all-or-nothing against a `private adapters` map (`llm/src/index.ts:399-460`). Only a registration's owner moves its routes, through `handle.replace`. + - `adapter-replacement.ts` deletes another plugin's entry, which is unsupported. Delete it. +- **Dispatch.** + - The `llm/stream` waterfall allows a short-circuit (`index.ts:62-75`). `next()` takes no arguments, and loop requests are frozen. + - Today's redirect, `llm.stream({ ...options, provider: hiddenRoute })` (`src/stream-hook.ts:251-266`), is supported but needs a registered hidden route. A route the plugin owns (F6, D13) keeps `adapterStream`'s validation, projection, replay filtering and failure normalization (`index.ts:1064-1150`) **without** one, so the redirect goes in P-R1. +- **Effort validation goes through the ADAPTER, not the public `resolveModelInfo`.** + - The paths are `resolveCallConfig` → `resolveModelInfoFor` → `adapter.resolveModel`, `adapter.prepareCall`, and pi's private `modelOf` (`index.ts:769,894-905,964,1076`; `llm-pi-ai/src/adapter.ts:369-375`). + - Today the adapter patch merges modes only for Go→Gemini, and through a hidden route (`models-discovery.ts:735-760`). Its catalog fill of context and levels for same-mode models (`:790-1010`) decorates the picker while dispatch validates against a different profile. Once the plugin owns the route (D13), one adapter answers both, in D17's order: the user's row, then the catalog, then pi. +- **Hiding.** + - There is no visibility flag (`LlmProviderInfo = {id, name}`). + - `listProviders()` is read by: + - the composer (`session-controller/src/catalog.ts:24`) and `modelAvailable` (`:81`); + - the Settings directory (`ui-settings-models/src/client/store.ts:233`); + - the subagent tool (`tool-subagent/src/list-models.ts:20,50`); + - ACP (`acp/acp/src/model-control.ts:158`); + - the SDK server (`sdk/server/src/server.ts:423`); + - the translator (`experimental/translator/src/deepseek.ts:69`). + - With no hidden routes (P-R1), **no listing patch is needed**; today's exists only to hide them from these readers. (Instance patches do reach Remote calls, because the gateway resolves methods with `Reflect.get` at call time — `api/gateway/src/index.ts:841-843` — which the `discoverModels` wrapper relies on.) +- **Settings discovery is keyed by the pi-ai entry id** (`llm-pi-ai/src/index.ts:138,156`; `ProviderEditor.tsx:254`; `ModelListEditor.tsx:167,244`). The plugin's own `registerModelDiscovery` namespaces are never asked. + - The host's normalization runs inside the original `discoverModels` (`index.ts:618-631`), so the wrapper must dedupe and whitelist the fields itself. + - `LlmDiscoveredModel` carries only id, name, contextWindow, maxTokens and inputModalities, with no reasoning field. +- **D1 representation.** Effort ids must be pi levels (`adapter.ts:170-181`), and two vocabularies mean opposite things by `null`: + - pi `thinkingLevelMap`: `{ minimal: null, low: null, medium: null, high: "high" }`, with **no `off` key**. `off: null` hides Off and suppresses the explicit disable (`openai-completions.js:674,690,712`; `anthropic-messages.js:917`). + - profile `reasoningEfforts` dict: `{ off: null, high: "high" }`. Here null means "offered, send nothing" (`llm-pi-ai/src/catalog.ts:705-712`). + - Picker: `{ efforts: [{ id: "off", name: "Off" }, { id: "high", name: "On" }] }`. + - Offer Off only where the protocol can disable reasoning (`adapter.ts:184-196`). + - Verify that `"high"` is what each protocol sends for "on" before relying on it. Today `"on"` goes out verbatim (`openai-completions.js:718`, `anthropic-messages.js:733-735`). + +## Defects found in this audit, and where each now stands (all verified in code) + +| Defect | Where | +| :-- | :-- | +| ~~The deprecated wrapper re-exports a `default` that does not exist, an ESM link error.~~ Fixed on `main` (#135); P2 retires the wrapper (D9). | `scripts/publish-scoped.ts:137`, `release.yml:124` | +| Settings shows pi's snapshot, not the catalog | `models-discovery.ts:142-198,532-539` | +| Mode merging is done only for Go→Gemini. For the other modes the picker shows catalog levels while dispatch validates against a different profile: toggle models show Minimal…Max with no Off, and Go `minimax-m3` + Xhigh/Max is refused on the internal Anthropic route. | `models-discovery.ts:607-616,735-760,1063-1203`; `pi-provider.ts:351-386`; `responses-provider.ts:111-150` | +| ~~`isZenProvider("opencode-google")` is false~~ Fixed on `main`: only `opencode-go` and `opencode-go-*` are excluded. P1a adds the regression test. | `utils/request-shape.ts:273-282` | +| ~~`enrichModelsResponse` turns a 401/403/5xx into a 200 catalog. It must still fill the Go plane's 404.~~ Fixed on `main` (#133): a refusal other than 404 passes through. P1a adds the Zen-plane 404 fill, the non-JSON 200 answer and the retry-after passthrough cases. | `models-catalog.ts:731` | +| ~~The warning token doesn't exist, so warnings render as caption text.~~ Fixed on `main` (#133): both use `--dsw-alias-state-warn-label`. | `settings-card.tsx:159`, `settings-store-inspect.tsx:250` | +| ~~The Settings spinner animates only if the meter is mounted~~ Fixed on `main` (#133): the card carries its own keyframes. | `settings-usage.tsx:45` | +| ~~The stream hook writes `debugFile` with `debug` off~~ Not a defect under D24: naming `debugFile` writes both halves. | `stream-hook.ts:327-340` vs `lifecycle.ts:361` | +| `settings-purge`: the `{ ok: false }` branch and the parser are on `main` (#133). ~~Still open: a refused purge whose `error` is the host's `RemoteError` object, not a string, shows the generic message.~~ Fixed by P1a: `settings-purge.ts` reads the `RemoteError`'s `message`. | `settings-purge.ts:56-69` | +| ~~The stream hook's `prepared` handling is dead. The host never reads `options.prepared`.~~ Fixed by P1b: deleted from `stream-hook.ts`. | `stream-hook.ts:44,200-203,258` | +| ~~The partition test misses `debugFile`~~ Fixed by P1b: `settings-fields.ts` registers it and the partition test walks `Config.dict`. | `test/settings-page.test.tsx:1223` | +| ~~`cordis.patch.yml`: phantom `showUsagePrice`, wrong `freeModelMarker: ''` text, incomplete field lists~~ Fixed by P1b: `test/manifest.test.ts` holds the header to the schema. | `cordis.patch.yml:23,36,61` | +| ~~`utils/retry.ts` uses `console` and global timers, against the purity gate~~ Fixed by P1b: `sleep` and `warn` are parameters (`backoffOver(timer)`). | `utils/retry.ts:31,55` | +| ~~`tool-fallback`: the Anthropic branch can't be reached.~~ Fixed by P1b: the Anthropic schemas are deleted. (The Go-first lookup is fixed on `main`; the plane rule is D25.) | `tool-fallback.ts:204,237-242` | +| Hard-coded catalog counts in tests, which the nightly refresh PR breaks by construction | `test/catalog.test.ts:94,200,208` | +| ~~`.freebuff/project-id` is tracked~~ Fixed by P1b: `.freebuff/` is ignored and untracked. | repo root | + +## Routing: the problem, the measured facts, the design + +**The problem, in the owner's words.** A provider defines one backend. Supporting every protocol and the newer catalog meant un-registering the original `opencode`/`opencode-go` provider and registering the plugin's own. Deleting another plugin's registration is unsupported, so the plugin mounted extra routes per protocol instead, merged their models back into the user's provider, and redirected dispatch. This section shows that the host **does** allow the original intent, through supported paths (F11–F13): keep pi-ai from claiming the two ids, and register the plugin's provider under them. A spike on the real host confirmed it (run S0). + +**Facts from the host's own code and data.** F1–F5 and F10 are measured: the data files were counted and the resolver read. F6–F9 and F11–F13 are read from the code, and run S0 exercised F11–F13 end to end. P-T1's real-host harness pins them all, and a fact that fails there sends the design back to this section. + +| # | Fact | Evidence | +| :-- | :-- | :-- | +| F1 | **One route already serves every mode, per model.** llm-pi-ai resolves `api` and `baseUrl` per model: `request.api ?? base?.api ?? routeApi` and `request.baseURL ?? base?.baseUrl ?? providerBaseUrl`, where `base` is pi's installed catalog entry. | `llm-pi-ai/src/catalog.ts:917-922` | +| F2 | _Measured on pi 1.1.0, the pin at the time; on the pinned 1.3.0 (D14') Go carries Google (F10) and pi stores the `/inference(/go)/v1` bases, so P-R1 re-measures this._ **pi's installed `opencode` catalog spans four protocols, each with its own endpoint.** In pi 1.1.0, which the host pins and patches: Zen has 18 anthropic-messages (`/zen`) and 7 google, 28 completions and 30 responses (`/zen/v1`). Go has 5 anthropic (`/zen/go`) and 19 completions and 6 responses (`/zen/go/v1`), and **no google**. | `@earendil-works/pi-ai@1.1.0` `dist/providers/data/opencode*.json`; the harness's `pnpm-lock.yaml:13` | +| F3 | **A model pi's catalog does not know cannot get its own protocol.** A profile entry has no `api`/`baseURL` field (`PiAiModelProfile`); it takes the route's `api`, or `sharedCatalogApi`, which is undefined for a multi-protocol catalog. | `llm-pi-ai/src/catalog.ts:600-640,681-691,917-921` | +| F4 | **Such a model cannot be saved, listed or dispatched on a pi-ai route.** Writes are validated strictly (`assertServiceable`), so the save is rejected with "needs an api". Stored reads are deferred, so the id lands in `modelErrors`. `listModels` lists only resolved models, and `modelOf` throws `INVALID_CONFIG`. | `llm-pi-ai/src/config.ts:357-368,487`; `index.ts:171`; `adapter.ts:271-311` | +| F5 | **A route-level `api` or `baseURL` overrides every model**, collapsing the modes onto one protocol or endpoint. | `llm-pi-ai/src/catalog.ts:917,922` | +| F6 | **Dispatch reads the route's adapter.** `selectModel`, `prepareCall` and `llm.stream` all go through `registration.adapter.resolveModel`/`prepareCall`. The host requires only that the prepared model's `provider` equals the route id, and then dispatches through `adapterCall.stream`, with validation, projection, replay filtering and failure normalization intact. | `llm/src/index.ts:774-793,894-905,956-964,1064-1150` | +| F7 | **`registerAdapter` is all-or-nothing against a private map**, and only a registration's owner moves its routes (`handle.replace`). A route nobody holds is free to register. | `llm/src/index.ts:399-460` | +| F8 | **Config resolution is a public, synchronous waterfall.** Cordis runs `internal/config` listeners in order, each getting `(config, next)`; `prepend: true` puts a listener outside those registered before it. pi-ai's save check (`assertServiceable`) is one listener on it. | the harness's `vendor/cordis/src/events.ts:234-258`; `llm-pi-ai/src/index.ts:177-186` | +| F9 | **opencode's own client serves four protocols from one provider, per model.** It picks the SDK from `model.provider.npm` and the endpoint from `model.provider.api ?? provider.api`, both from the catalog. The plugin's catalog carries the same data (`catalog-data.ts` `OPENCODE_FAMILY_API`, a per-model SDK). | opencode's `packages/core/src/models-dev.ts:85-93` | +| F10 | _Gone on pi 1.3 and later, whose `providers/opencode-go.js` wires `google-generative-ai`: the row flips to a baseline, and the plane adapter's Go Google wiring is equivalent rather than a gap-fill._ **pi 1.1.0's `opencode-go` provider has no Google API.** Its api map is anthropic-messages, openai-completions and openai-responses only, each wrapped in `withOpenCodeSessionHeader`. pi exports each protocol separately (`api/*.lazy`: `anthropicMessagesApi`, `openAICompletionsApi`, `openAIResponsesApi`, `googleGenerativeAIApi`) plus `createProvider` (`models`), which is what `google-bridge.ts` `googleProviderFor` already composes from. | pi 1.1.0 `dist/providers/opencode-go.js`; its `package.json` `exports` | +| F11 | **pi-ai's routes are exactly the keys of its _resolved_ config.** It registers `[...profiles().keys()]`, and `profiles()` reads `config.providers.get()`, the value the fiber resolved. | `llm-pi-ai/src/index.ts:166-175,293-312` | +| F12 | **Every path to that resolved value goes through the `internal/config` waterfall, and the fiber keeps the waterfall's _return value_.** That covers the first load, `fiber.update`, the loader's volatile commit, and the config editor's validation. **The config editor writes the raw `next` to `cordis.patch.yml`, never the resolved value.** So an outermost listener that returns a copy without `providers.opencode`/`providers["opencode-go"]` keeps pi-ai from seeing or registering them, while the user's file keeps every entry. | the harness's `vendor/cordis/src/fiber.ts:641-643,736-752` and `vendor/loader/src/config/entry.ts:164-195`; `boot/config-editor/src/index.ts:101-123` | +| F13 | **The plugin can then register those ids through the public API.** With no pi-ai entry for them, `registerAdapter(["opencode", "opencode-go"], adapter)` passes the duplicate check. `PiAiAdapter` is a **public export** of `@deepseek-ai/dsh-llm-pi-ai`. pi-ai's Settings directory keeps listing both ids, because it lists every installed catalog provider regardless of profiles, with `settingsNs` = pi-ai and `settingsPath = ["providers", id]`. So Settings edits the same entries in the same file as today. | `llm/src/index.ts:399-460`; `llm-pi-ai/src/index.ts:77,128-150` | +| F14 | **A commit and a validation are told apart by identity.** The config editor runs the waterfall on a fresh candidate only to validate it, and can still reject the edit after that run. Every commit path passes the object stored in `fiber._config`, so `raw === this._config` holds for a commit and for nothing else. | `boot/config-editor/src/index.ts:101-148`; the harness's `vendor/cordis/src/fiber.ts:229,655,738,747` and `vendor/loader/src/config/entry.ts:149,171` | +| F15 | **An edit that changes only a plane entry emits nothing.** It raises no `loader/volatile-update` and does not restart pi-ai. The listener's commit run is the only signal it happened. | the harness's `vendor/loader/src/config/entry.ts:177-187` | +| F16 | **The outermost listener sees un-interpolated `{__jsExpr}` nodes.** The loader interpolates only the value `next()` returns. | the harness's `vendor/loader/src/index.ts:104-113` | +| F17 | **Ordering must be explicit.** `restart()` is swallowed while a fiber is loading or unloading, loader rows load in parallel, and cordis disposes effects concurrently. | the harness's `vendor/cordis/src/fiber.ts:625-639,665-686` and `vendor/loader/src/config/group.ts:56` | +| F18 | **`PiAiAdapter.listModels` answers `[]` on `MISSING_CREDENTIAL`.** | `llm-pi-ai/src/adapter.ts:295-311` | +| F19 | **`PiAiAdapterOptions.auth` is required.** pi-ai does not export its credential-store or auth-context builders; it does export `PiAiAdapter`, `Config` and `recordKeyFor`. | `llm-pi-ai/src/adapter.ts:98-106`; `index.ts:77-92` | +| F20 | **The Settings page reads pi-ai's resolved config.** `describe()` builds `value` from `fiber.config`, so once the claim strips the planes they read as unconfigured. The gateway resolves service methods per call (`Reflect.get`), so an instance wrapper on `describe` is seen by `settingsController.describe` and by `write()`. | `settings/settings/src/index.ts:319`; `api/gateway/src/index.ts:841-843`; `api/settings-controller/src/index.ts:98-103,217` | + +Three corrections to the facts above: + +- **F12, the mechanism.** Cordis passes listener arguments unwrapped (the harness's `vendor/cordis/src/utils.ts:118-125`). The first prototype's in-place trim failed because the fiber keeps the waterfall's return value, not because of a proxy. +- **F13, the read view.** Settings writes the same file, but its read view (`describe().value`) loses the plane entries. The plugin grafts them back (D18, F20). +- **Run S0's other routes.** `deepseek-official` and `deepseek-account` belong to the `llm-deepseek-*` plugins, not to pi-ai. + +**What the plugin has to add, and nothing else:** + +- **G1:** _Measured on pi 1.1.0, the pin at the time; on the pinned 1.3.0 (D14') Go carries Google (F10) and pi stores the `/inference(/go)/v1` bases, so P-R1 re-measures this._ serve any declared model the host's pi cannot resolve (D15). That means models newer than pi 1.1.0's catalog (8 Zen and 4 Go, including every Gemini model on Go, F10), **and** ids the catalog doesn't know at all. +- **G2:** _Measured on pi 1.1.0, the pin at the time; on the pinned 1.3.0 (D14') Go carries Google (F10) and pi stores the `/inference(/go)/v1` bases, so P-R1 re-measures this._ the endpoint family. pi 1.1.0 stores the legacy bases; the plugin sends the per-protocol `inference` bases, or the legacy ones when the `endpoint` setting says so. +- **G3:** a profile-level `api`/`baseURL` (F5). It is the user's explicit choice and is honoured as one (D8), without collapsing the modes. + +### The design: the plugin owns the plane routes (D13) + +#### `src/host/route-claim.ts`: pi-ai never claims the planes + +- **Where the listener lives.** The plugin root declares `inject: []`; today it is `["llm"]`. The `internal/config` listener (`prepend: true`) and the stream and fetch hooks live on the root, and everything that needs `llm` runs inside `ctx.inject(["llm"], scope => …)`. + - pi-ai is identified by `this.runtime?.name === "llm-pi-ai"`, never by `this.name`, which pi-ai's inject children inherit. + - Its fibers are found through `ctx.registry` runtimes. +- **What the listener does.** It is a pure filter and never mutates its input: + 1. It strips the two plane keys from a fresh copy. + 2. It runs the nested waterfall on the copy and returns that result. + 3. On a commit (`raw === this._config`, F14), it schedules `reconcile` with `queueMicrotask`. + 4. On an editor candidate, it strictly validates the plane entries that changed. A malformed save is rejected in pi-ai's message style. + + It never throws on a commit run, because a throw there would fail pi-ai's fiber and every route it holds. + +- **What `reconcile` does.** It reads the committed plane entries from `piFiber._config.providers`, memoized by object identity plus a JSON comparison. Then: + 1. It interpolates `{__jsExpr}` nodes with the loader's exported `interpolate` (F16). Without a loader, a node left over becomes a per-model `INVALID_CONFIG`. + 2. It normalizes the entries with pi-ai's exported `Config`, and builds the profiles. + 3. It reconciles the route set: + - with no handle and at least one plane, it calls `registerAdapter(routes)`; + - with a handle whose route set, `displayName` or `retryPolicy` changed, it calls `handle.replace(routes)`. `replace([])` is legal; `registerAdapter([])` is not. + + Only the planes the user declared are registered. `reconcile` also runs on a global `llm/adapters-updated`, and on `loader/partial-dispose` for pi-ai. + +- **Mount.** It runs inside apply's returned promise, never from a timer: + 1. Register the teardown effect first. + 2. Install the listener. + 3. If pi-ai holds a plane, `await piFiber.await()`; a FAILED pi-ai is tolerated. Restart pi-ai only if it is ACTIVE and its resolved config still holds a plane, then check again. + 4. `registerAdapter` is wrapped in try/catch. On `DUPLICATE_ADAPTER` it logs and retries on the next `llm/adapters-updated`, after `piFiber.await()`. +- **Teardown.** A single effect, registered first so that it runs last: + 1. It calls the handle, then sets the listener inert, with no await between the two. + 2. Only on a real dispose (`ctx.fiber.uid === null`), with pi-ai ACTIVE and its parent not unloading, it awaits pi-ai, restarts it so it takes the routes back, and checks the result through `listProviders()`. + + Reloading the plugin's own configuration restarts pi-ai zero times. + +#### `src/host/plane-provider.ts`, `src/utils/plane-profile.ts` and `src/utils/plane-model.ts` + +- **Where the classes come from.** `PiAiAdapter` and `Config` come from the host's llm-pi-ai module, through the existing `loadPiAiModule`. The host's pi comes through `loadHostPi`. Both lookups sit behind one function in `cordis-context.ts`, which the harness can replace. The registry and `adapter.config` are never read. +- **`class PlaneAdapter extends PiAiAdapter`.** It overrides `listModels`: when `super` answers `[]` or throws `MISSING_CREDENTIAL` (F18), it answers with the declared ids. Dispatch still fails loudly. +- **Auth (F19).** + - **Credentials.** A credential store over `ctx.credentials`, keyed by the exported `recordKeyFor(route)` and using pi-ai's record mapping, so a host Sign-in record under `llm-pi-ai/opencode` keeps working. + - **Environment.** `authContext.env(name)` reads the credentials service first, then the launch environment. + - **Provider auth.** The composite provider's `auth` is pi's own `opencodeProvider().auth` / `opencodeGoProvider().auth`. + - **Keys.** `resolveApiKey` follows D20. +- **`utils/plane-profile.ts` is pure and never throws.** It builds a `ResolvedPiAiProviderProfile` from a raw entry. Every field comes from the entry or the plugin's constants, and none is copied from a sibling profile. It sets: + - `Config`'s defaults and every `PiAiProviderProfile` field; + - `configuredMaxTokens`; + - `retryPolicy`, through dsh-llm's `resolveRetryPolicy`; + - `modelErrors` and `catalogError`, from a mirror of pi-ai's semantic checks minus "needs an api" (D15); + - `piProvider`, built with `createProvider({ id, auth, api, models })`. `api` maps all four protocols, each wrapped in `withOpenCodeSessionHeader(Api())`, from the host pi's public exports. +- **`utils/plane-model.ts` `planeModelFor` is pure.** Each field takes the first source that has it: + + | field | rule | + | :-- | :-- | + | protocol | catalog SDK (`PROTOCOL_FOR_SDK`, with `@ai-sdk/mistral` → completions as pi's own data has it) > pi's entry > the user's route-level `api` (only for ids neither classifies) > `openai-completions` (D16) | + | name, context, max tokens, modalities, cost | the user's row > pi-ai's route-level `modelOverrides` > catalog > pi > defaults (D17) | + | reasoning levels | the user's `reasoningEfforts` > `reasoningFor(spec)` from the catalog (D1: a toggle model offers Off/On, in both vocabularies) > pi > none | + | baseUrl | the route-level `baseURL` > the `endpoint` family table | + + The baseUrl row resolves as follows: + - A route-level `baseURL` on a vendor root (`/zen…`, `/inference…/openai/v1`, `/inference(/go)/v1`) selects that family. + - A foreign root is used as written, except that a trailing `/v1` is stripped for anthropic-messages, because pi's Anthropic SDK appends `/v1/messages`. + - The family table defaults to `modern`; `auto` means `modern`. + - `legacy` takes the base from pi's own entry, with the fixed table as the fallback. Go has no legacy Google base, so that cell uses `OPENCODE_FAMILY_API.go["@ai-sdk/google"]`. + +- **`src/utils/protocols.ts`**, created in P-R1, holds the SDK constants, `PROTOCOL_FOR_SDK` and `isServableSdk`. `UNSERVED_*` goes. + +#### `src/host/patch-method.ts`: the two host wrappers that remain + +`patch-method.ts` restores a wrapper only if it is still the current one, deletes it when the original was inherited, and re-wraps on `llm/adapters-updated`. It installs two wrappers: + +- **`settings.describe` (D18, F20).** It runs inside `ctx.inject(["settings"])`, and touches only the `llm-pi-ai` descriptor. When `value.providers[plane]` is missing, it sets it to the interpolated, `Config`-normalized committed entry; `base`, `user` and the revision are untouched. A contract test pins that no node under `providers` in pi-ai's `Config` carries `role("secret")`. Today only `apiKeyEnv` has a role, and it is `credential-ref`. +- **`discoverModels` (P3).** It answers pi's rows plus the catalog's missing ones, deduped and whitelisted. A `NO_DISCOVERY` while pi-ai restarts yields catalog-only rows. + +#### Deleted + +- the llm-pi-ai child mount and its `llmFacade`; +- `responses-routes.ts`, and the hidden ids in `DEFAULT_PROVIDERS` and `cordis.patch.yml`; +- the listing hide; +- the stream redirect and `isAvailable`; +- `decoratePlaneAdapters`, and the plane overrides in `decorateModelInfo`; +- the Gemini bridge registration; +- the Google replay translators, if replay stays on one provider label (a test decides); +- `protocolFromModelId`, `adapter-replacement.ts`, and the D19 settings. + +#### Removing the plugin + +pi-ai serves the models it knows again, from the same file. Ids pi can't serve drop out of the composer, and the next Settings edit of that provider is refused until they are removed. Three things cover this: + +- a README section; +- a handback test that pins the behaviour; +- a card action that lists those ids and removes them (P10b). + +### Measured on the real host + +**Runs.** + +- Runs A–D use the published `@deepseek-ai/dsh` 0.2.1-rc.2 with the pi 1.4.0 it installs, offline, with an instrumented fetch. +- Runs S0 and S0b swap pi for **1.1.0 plus the harness's own patch** (the pin of the time, D14; D14' now pins the alpha tag's 1.3.0). +- The prototypes are throwaway, and are not in the repository. + +| Run | Registered | Known models, 4 shapes × 2 planes | A model pi lacks | Saving a model pi lacks | +| :-- | :-- | :-- | :-- | :-- | +| A: plugin 1.4.1 as shipped | `opencode`, `opencode-go` + 6 hidden routes | served through the hidden routes, on the plugin's per-shape endpoints | **not listed, `INVALID_CONFIG`, never sent** | **rejected** ("needs an api") | +| B: pi alone | `opencode`, `opencode-go` (pi-ai) | served, each in its own protocol, on pi's endpoints | same failure | **rejected** | +| C: pi + a delegating prototype (save gate, unregistered catalog adapter, adapter patch) | `opencode`, `opencode-go` (pi-ai) | served by pi | listed, validates, sent | accepted for catalog models; **a catalog-unknown id still rejected (run D)** | +| **S0/S0b: pi 1.1.0 + route claim + plugin provider** | `opencode`, `opencode-go` → **the plugin's adapter**; pi-ai's `deepseek` route unchanged; no hidden routes | all listed and validated, and each sent in its own protocol: `/inference/openai/v1/chat/completions` and `/responses`, `/inference/anthropic/v1/messages`, `/inference/google/v1beta/models/…:streamGenerateContent`, and the same paths under `/inference/go/` on Go, **Gemini on Go included** | Zen `gpt-5.3-codex-spark` → `/responses`, with the user's `contextWindow` reaching `resolveModelInfo`; Go `minimax-m3.1-flash` → completions; Go `gemini-4-argon-contributor` → Google | **`not-a-real-model` accepted**: the file holds exactly the saved entry, which is then listed, validates and is sent as completions | + +**What the runs did not settle.** + +- **Handback.** S0's handback **failed**: both planes ended at `NO_ADAPTER`, because the prototype released them while its listener was still live. S0b, after that fix, handed both routes back, and pi-ai then listed what pi 1.1.0 knows. +- **What the spike exercised.** It claimed the routes 2 s after boot, against an ACTIVE pi-ai, and simulated the release rather than disposing a fiber. P-T1b and P-R1 measure the real lifecycle. +- **A Google failure code.** A 401 on a Google call surfaced as `PI_AI_ERROR` rather than `AUTH`, exactly as with pi alone (run B). That is pi's error mapping. +- **Endpoints.** The sandbox blocks `opencode.ai`, so P-R2 measures the endpoints in CI. + +**Fallback, only if the P-T1b harness contradicts S0:** run C's shape. That shape has three parts: + +- an unregistered catalog adapter per plane, serving the gap models; +- an adapter patch on pi-ai's route adapter, delegating those models to it; +- a save gate that validates a copy of the config without them, then absorbs only pi-ai's "needs an api" refusal for those ids. + +## Target architecture + +``` +src/ + index.ts apply, Config, inject, name + config types (D6) + lifecycle.ts apply(): installers only + host/ + patch-method.ts wrap a host method: restore only if still current; delete when the original + was inherited; re-wrap on llm/adapters-updated (settings.describe, discoverModels) + discovery.ts discoverModels: pi's answer + the catalog's missing models (D3, D8) + route-claim.ts prepended internal/config filter: pi-ai never claims the planes; reconcile on + commit; mount and teardown ordering (F14-F17) + plane-provider.ts PlaneAdapter (extends the host's PiAiAdapter), registered as the declared planes + remote.ts installRemoteService, markRemote + request/ fetch patch stages: claim → credential → headers → body (body parsed once) + credentials.ts gathers values; utils/credential-policy decides; CaptureStore per root + utils/ pure: protocols, plane-model, plane-profile, model-metadata (reasoning, both + vocabularies), credential-policy, request-shape, session-id, catalog-parse + contracts/ defineRemote(namespace, methods) for usage, purge, store (client-safe) + settings-table.ts one field table → schema, resolver, types, card specs, copy, manifest + ui/ card, one ActionRow, meter, on the primitives +``` + +The target removes the workaround's moving parts. There are no hidden routes, no listing hide, no stream redirect and no child mount. There is no Gemini bridge registration, no patched host adapter and no `resolveModelInfo` override (D8). + +What stays private is pinned by contract tests against the real host packages: + +- the `ResolvedPiAiProviderProfile` shape; +- pi-ai's credential record mapping; +- parity with pi-ai's own profile resolution; +- the two wrapped host methods, `describe` and `discoverModels`. + +## Phases + +**How each phase opens:** + +- **Behaviour phases open with a red test:** P1a, P1b's fixes, P2, P-R1, P-R2, P3, P6 and P10b. +- **Pinning phases open with a green pin**, which the PR shows unchanged: P-T1a/b, P-H, P5, P7, P8, P9, P10a and P-T2. +- **P-T3 and P12 are exempt.** + +**Characterization rows are tagged when they are recorded**, so a later phase flips named rows: + +- `KNOWN_DEFECT`: + - Go Gemini answers `INVALID_CONFIG`; + - an unknown id is rejected; + - toggle models show Minimal/Xhigh/Max. +- `INTENDED_CHANGE`: + - completions models move from `/zen(/go)/v1` to `/inference(/go)/openai/v1`; + - `decoratePlaneAdapters`' baseUrl repair never reaches the wire. + +1. **P1a `fix:` the purge row's refusal and the endpoint copy.** The rest of what shipped broken in 1.4.1 was fixed on `main` (#133, #135) before this phase landed. + - `settings-purge` shows the `message` of a refusal whose `error` is the host's `RemoteError` object. + - The endpoint copy (`config.ts`, `config-values.ts`, the card, both READMEs) names the per-protocol inference family for `modern`, with `auto` as its alias (D19), pinned by `endpoint-copy.test.ts`. + - Regression tests for `isZenProvider("opencode-google")`: the predicate, its agreement with `catalogPlaneForRoute`, and the usage service's Zen fallback on that route. + - The warning colour is the bare `--dsw-alias-state-warn-label` (no token fallback), and every token the card names is checked against the host's vocabulary. + - The stream hook's debug test asserts D24 rather than the gate this phase first proposed. + - The catalog's refusal tests gain three cases `main` lacks: the Zen-plane 404 fill, the non-JSON 200 answer, and a `retry-after` header that survives the passthrough. The passthrough itself is on `main` (#133). +2. **P1b `chore:` hygiene.** + - Delete `adapter-replacement.ts` and its test, with a note in the engineering notes on why the approach is unsupported. + - Delete the dead `prepared` handling and `.freebuff/`. + - The partition test walks `Config.dict`, and a manifest test checks `cordis.patch.yml` against it. + - `retry.ts` takes its timer and logger as parameters. The architecture test bans `console` and timers in `utils/`, and walks the folder recursively. + - Remove the dead branches in `tool-fallback`. + - Add the `test/support/src.ts` indirection. + - On the restack: the tool fallback keeps `main`'s plane rule (D25), so only the unreachable Anthropic schemas go; the catalogue fixture this phase wrote and `main`'s `test/fixtures/catalogs.ts` become one module under `test/support/`, which reaches `src` through the door. +3. **P2 `feat:` a smaller public API.** It ships as a 1.x minor. + - `index.ts` exports `apply`, `Config`, `inject`, `name` and the config types. + - `./src/*` goes from `exports`, and `check.ts` asserts that every exports target is inside `files`. + - Delete `resolveRoutedKey` and `internalRoutes`. + - Stop publishing the wrapper. `publish-scoped.ts`'s `SCOPED_TARGETS` and `release.yml`'s verify list change together, and `check.ts` asserts that they agree. +4. **P-T1a `build:` pin the host.** + - `pnpm-workspace.yaml` keeps `main`'s pins, and gains `overrides: { "@earendil-works/pi-ai": "1.3.0" }` and `patchedDependencies`, with a committed copy of the alpha harness tag's `@earendil-works__pi-ai@1.3.0.patch` (D14'). The lockfile is regenerated. + - The `@deepseek-ai/*` packages stay on `main`'s alpha.2 cluster (D22'), and the devDependencies gain `dsh-llm`, `dsh-app-boot`, `dsh-config-editor`, `dsh-settings` and `cordis-plugin-loader`. + - Add `test/support/host-versions.ts`, with a version test: `loadHostPi().version` is 1.3.0, and the root pi is the same copy. + - Rewrite `mount-protocols.test.ts` as "every catalog SDK maps to a protocol that pi's `api/*` exports implement"; the row that pi's Go provider lacks Google flips to a baseline (F10). +5. **P-T1b `test:` the real-host harness.** It is additive only. `test/support/host.ts` provides two tiers that run inside the test process: + - **Tier A:** a cordis `Context`, `LlmRuntime`, and pi-ai mounted with `{ providers }`. + - **Tier B:** dsh-app-boot's `boot()` with a temporary HOME, and a test bundle that names the loader builtins `llm`, `llm-pi-ai`, `ConfigEditor`, `Settings` and the plugin. It follows the harness's `experimental/speech-to-text/tests/configuration.spec.ts:7-33`, with a fake `credentials` service and a launch-environment snapshot. + + This phase lands: + - the contract tests; + - the tagged characterization rows; + - the S0 assertions, including `settings.describe`, `hasProviderApiKey` and the Models store's join; + - the CI additions below. + +6. **P-H `refactor:` one way to patch a host method.** `host/patch-method.ts` and `test/reload.test.ts` (apply A → apply B → dispose A → B intact → dispose B → originals). +7. **P-R1 `feat:` the plugin owns the plane routes.** + - **New:** + - the design above; + - D1, D16, D17, D19 and D20; + - the `settings.describe` wrapper; + - `utils/protocols.ts`. + - **Deleted:** the hidden-route machinery and the D19 settings. + - **Tests:** + - retarget `protocol-routing.e2e.ts` and `dispatch-levels.e2e.ts` to the planes; + - move the per-file coverage floors to the new modules, at 100 for the pure ones, and check that every floor's glob matches a file. + - **Docs:** + - the README, including "Removing or disabling the plugin" and "Changed in 1.x"; + - `docs/redirect-planes.md` §8-9 and `docs/protocol-routing-and-merge.md`; + - AGENTS.md's repo map and CONTRIBUTING.md. + - **The PR body** names every tagged row it flips. +8. **P-R2 `fix:` the endpoint family, measured.** + - Probe 3 URL shapes × 4 protocols × 2 planes, keyless, plus keyed probes per family with the CI keys. + - Set the default family and pin it with a test. If a `modern` cell fails, flip the default; `endpoint: legacy` still maps every protocol. +9. **P3 `feat:` Settings complements pi's list.** + - The `discoverModels` wrapper answers servable, non-deprecated models, deduped and whitelisted. + - Delete `installModelDiscovery` and `toDiscovered`. + - Rewrite the "adds nothing" tests, and the docs that say the same. +10. **P-T2 `test:` suite structure.** + - Move the mock-booting unit files into `test/mock/`. + - Retire the ad-hoc contexts, `createMockContext` and `HarnessSimulator`. + - Turn constants into invariants, and split the seven big files. + - One shared e2e environment helper. + - Cover `session-id` and `opencode-store-contract`. +11. **P-T3 `ci:` CI and scripts.** + - **The test run:** one unit run; build before the tests that read `lib/`; type-check `test/**/*.tsx`. + - **Setup:** one composite setup action. + - **`check.ts`** keeps the lib freshness check, the consumer install and the size tripwire. + - **Scripts:** the generators share helpers, and the `verify-*` scripts exit non-zero. + - **`no-local-paths`** also covers `.github/` and `*.sh`. +12. **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. +13. **P5 `refactor:` installers.** `installRemoteService` replaces the three mount-once copies. +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. + - A parity test over the deciders, with the adapter path as its own row: a rotated key takes effect, and a service failure propagates. + - A `CaptureStore` per root, evicting a key on 401. +15. **P7 `refactor:` one source per table.** One `planeForRoute` (four copies today), `formatUsd` and `specFor`; delete the duplicate `ALL_THINKING_LEVELS` and the capitalisation copies. +16. **P8 `refactor:` settings table.** It starts with a snapshot test of the derived `Config`. Then one field table drives the schema, resolver, types, card, copy and manifest. +17. **P9 `refactor:` remote seam.** `defineRemote`, `markRemote` and `callRemote`, with the parity test reading the contracts. +18. **P10a `refactor:` pure module moves.** + - `models-discovery` → `host/*`; + - `go-discovery` → `credentials.ts`; + - split `usage-ui`; + - `fetch-patch` → `request/`. +19. **P10b `fix:` catalog state.** + - The parse is pure and returns `deprecated`, and a refresh replaces the maps. + - The purge makes one edit per entry. + - The card lists the ids pi can't serve, and removes them. +20. **P11 `refactor:` UI on the primitives.** +21. **P12 `ci:`** make lint warnings gating once `src/` is at zero. + +## Order + +``` +P0 → P1a → P1b → P2 → P-T1a → P-T1b → P-H → P-R1 → P-R2 → P3 → P-T2 → P-T3 → P-R3 → P5 → P6 → P7 → P8 → P9 → P10a → P10b → P11 → P12 +``` + +- **P1a first:** it fixes what already shipped broken. +- **P-T1a and P-T1b before any routing change:** the real-host harness records today's behaviour, and P-R1 must keep it except for the rows it names. +- **P-H before P-R1:** P-R1 needs the `describe` wrapper. +- **P10a only after P5–P9:** a module is moved only once tests pin what it does. +- **P-R3 only after P-R2's re-measure:** it changes how refusals are reported, so the refusal shapes have to be recorded first. + +## Restack adjustments + +How each phase changes now that it sits on `main` 1.8.0 and the alpha host. Each open phase merges its parent; only P0 merges `main`. Generated files take `main`'s blobs, catalog counts are re-derived from the shipped shim, the README pair moves in step (`test/docs-pairing.test.ts`), and the repository map stays true in both directions (`test/no-local-paths.test.ts`). + +| Phase | Adjustment | +| :-- | :-- | +| P0 | Shrinks: the three catalog refreshes and the per-plane pricing fix are on `main`. Keeps the suite hardening, the lifecycle, mount and credential fixes, D23's scoping with `--copies`, and this plan. | +| P1a | Shrinks hard: the purge row shows the host's `RemoteError.message`, the endpoint copy says `auto` is `modern`, and `isZenProvider("opencode-google")` gains a regression test. The wrapper fix, refusal passthrough, warning token, spinner and `parsePurgeResult` are on `main` or superseded; P1a only drops the warning token's fallback, checks every token the card names against the host's vocabulary, and adds three refusal-passthrough test cases `main` lacks (the Zen-plane 404 fill, the non-JSON 200 answer, the `retry-after` passthrough). The debug gate follows D24. | +| P1b | Takes `main`'s `coreToolDecision` (D25) and ports only the dead Anthropic branch's deletion; its catalogue fixture merges with `main`'s `test/fixtures/catalogs.ts` behind the one door. | +| P2 | Re-retires the wrapper `main` revived (D9); the door list grows by every `src` name `main`'s tests import. On the merge, P1b's and P2's door-rule lines in `CONTRIBUTING.md` conflict; take P2's, which names the same exceptions and adds its gate. | +| P-T1a | Redone on D14' and D22': `main`'s pins, then the pi 1.3.0 override and patch. **Open before it starts:** D14' and D22' verify one channel, while the README's "Verified on" line and `harness-channel.test.ts` name both channels' latest hosts, and the rc host pins an older pi with its own patch and its own defaults (D17), so a peer range cannot stand for it. The owner decides, and the outcome is recorded as D14'' and D22''. The cheapest coverage is a per-channel `HOST_VERSIONS` and an rc row in the e2e-host matrix, which installs into a throwaway prefix: a floating rc install first, and a pinned rc cell with that tag's patch only once the latest rc tag's pi pin is confirmed. The committed install and lockfile stay on the alpha cluster, and the 1.1.0 patch is kept until the decision. | +| P-T1b | Re-measured on the pinned cell (pi 1.3.0, patched) and the floating one (1.5.0 today); floating rows are version-agnostic; contract-test citations move to cordis `4.0.6-alpha.1`. | +| P-H | Gains the Chinese "Changed in 1.x" bullet the pairing gate requires. | +| P-R1 | Restates pi-ai's defaults (D17); deletes `test/google-thinking.test.ts` with `src/google-bridge.ts`, moving its intents to the mock suite; takes `main`'s model lists with its own routing prose. | +| P-R2 | Splits the free probe's 429: `FreeUsageLimitError` is its own non-retryable verdict, not a rate limit; `ModelDeprecated` reads as gone; adds the probe rows of lessons L3, L4, L5, L9, L10 and L13; ports `main`'s mock-coverage gate (#142) over the dispatch tables. | +| P3 | Unchanged seams on the alpha host; its Settings fetch row runs in both binary cells. | +| 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 | **Shrinks.** `main` already extracted the scripts' pure halves and wrote `docs/testing.md`. Remaining: one unit run with the build before the `lib/` readers, `test/**/*.tsx` type-checked, the composite setup action, `check.ts` trimmed to freshness, consumer install and size, `verify-*` exit codes, and `no-local-paths` over `.github/` and `*.sh`. **New:** the CLI-version script reads the registry's dist-tags and the 2.x User-Agent format (L6), and a matrix cell runs unit and tier A on the floating pi (L13). | +| P-R3 | **New**, after P-R2's re-measure: L2's error mapping in `prepareCall`, L7's `tool_choice` and schema check, L9's `providerRetryAfterMs`, and L10's Off through `none` if measured. | +| P5 | Unchanged, plus L1's warn-once when `globalThis.fetch` is no longer the patched function. | +| P6 | L8: verify before evicting a key; L4's outcome (drop `x-opencode-request`, or send it only where it is accepted). | +| P7 | L6's client-generation table and L11's context-window overrides table; the `planeForRoute` copies now include `main`'s `isGoPlaneUrl` and `catalogPlaneForRoute` pair. | +| P8 | The health snapshot shows the catalog's `lastError` on the card. | +| P9 | A loopback triple guard, only if a `webServer` route appears. | +| P10a | L1's per-call shaping is the first move of `fetch-patch` into `request/`. | +| P10b | L12's test; optionally emit `llm/models-updated` on refresh, a new host event. | +| P11, P12 | Unchanged. | + +## Lessons from opencode2dsh + +Two community plugins solve the same problem on the same host: [`FishBottle7/opencode2dsh`](https://github.com/FishBottle7/opencode2dsh) (read at `3805a38`, cited as FB) and [`satanfire/opencode2dsh`](https://github.com/satanfire/opencode2dsh) (read at `873e914`, cited as SF). Ranked by value. "Adopt" means the evidence is in the cited code; "measure" means P-R2 records it before any code moves. + +| # | 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. | P-R1 follow-up (the doc fix), P10a, P5 | 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 | +| 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 | +| 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 | +| 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 floating pi, 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-T1b's floating cell, P-T3's matrix cell, P-R2 | Adopt | +| L14 | Smaller: a triple loopback guard if a `webServer` route is ever added (the remotes seam remains the better one); the host's `volatileForm()` as an alternative to hand-written card fields; the catalog's `lastError` on the card rather than in a file. | FB `packages/plugin/src/ip-pool-settings/bridge.ts`, `packages/plugin/src/config.ts`. | P9, P8 or P11, P8 | Adopt where the phase reaches it | + +**Do not adopt:** SF's Bun transport and its `` prompt injection (which also leaks the host server's working directory); FB's IP pool, sibling-host replay and credential-custody delegation; automatic retry with thinking turned off; FB's own stream watchdogs (the host has one). These circumvent the vendor's stated gate ("Free models work in OpenCode"); the vendor answers with IP blocks, and these features would expose our users' keys. Classify the refusals honestly instead (L2, L3). Also avoid their session-id scheme, which is derived from the first user message and re-minted per process: ours is stable per DSH session (`src/utils/session-id.ts`). + +## Test matrix + +**P1a–P2** + +- **Unit:** + - each shipped breakage, red before its fix; + - the wrapper is retired (P2, D9), so it has no forwarder to test; + - a snapshot of the endpoint copy; + - the manifest is a subset of `Config.dict`; + - the `src.ts` indirection; + - the public API, pinned green and then shrunk; + - `exports` targets are inside `files`; + - the scoped publish list matches `release.yml`'s; + - the deleted settings are gone everywhere. + +**P-T1a** + +- **Unit:** + - the version pin, and a same-copy check on the realpath; + - the rewritten `mount-protocols` test. + +**P-T1b** + +- **Real host, tier A:** `listProviders()` equals pi-ai's keys (F11). +- **Contracts:** + - the listener fires for another plugin's fiber, and `prepend` puts it outermost; + - `raw === _config` holds for `_reload`, `update` and `_commitVolatile`, and not for the editor's candidate (F14); + - a deep-frozen input is never mutated, and the fiber keeps the return value (F12); + - the profile shape, the `Config` defaults, the credential record mapping and `recordKeyFor`; + - no secret-role nodes; + - the loader's `interpolate`; + - pi's public exports. +- **Real host, tier B:** + - `configEditor.edit` writes the raw `next` to the YAML; + - `_commitVolatile` re-resolves through a prepended listener; + - baselines for `settings.describe` and `hasProviderApiKey`. +- **Characterization rows**, tagged, on the pinned pi (D14'), with the floating cell's rows version-agnostic. + +**P-R1** + +- **Unit:** + - `planeModelFor`, one row per field × known or gap model × `modern` or `legacy` × vendor or foreign base, including the Go Google legacy cell and `auto` meaning `modern`; + - `plane-profile`: + - its field mapping and defaults; + - a malformed-entry table: wrong types, an empty or duplicate id, an invalid header, a `null` effort on anything but off, removed keys; + - it never throws; + - `!!js` is interpolated first; + - `withoutPlaneProviders` makes a fresh copy and never touches a frozen input; + - the `protocols.ts` tripwire. +- **Real host, tiers A and B:** + - **Ownership and restart counts:** + - pi-ai first: one restart; + - plugin first: none; + - both registry orders in one batch, with no `DUPLICATE_ADAPTER` escaping; + - pi-ai FAILED: nothing claimed; + - `listProviders()` restored after every transition. + - **Lifecycle:** + - dispose, then apply again; + - a config reload restarts pi-ai zero times; + - an `llm` restart; + - pi-ai restarting itself while claimed; + - a stream in flight completes; + - after dispose, the routes are handed back, and the next Settings edit of `opencode` is refused. + - **Config flow:** + - a save rejected by a home patch or by a sibling provider changes nothing; + - a plane-only save is served without a restart; + - a `displayName` edit goes through `replace`; + - a purge edit is accepted; + - a malformed candidate is refused; + - a malformed stored entry leaves `deepseek` serving, with one `INVALID_CONFIG`. + - **Route set:** + - one plane only; + - the first plane added at runtime; + - the last plane removed, through `replace([])`; + - a plane re-added; + - booting with none. + - **Credentials:** + - the credentials service's environment and a Sign-in record both authenticate; + - a missing named ref still lists the models, and dispatch fails with `MISSING_CREDENTIAL` without a request; + - a service failure propagates; + - a rotated key takes effect; + - captured keys are never used. + - **Isolation:** a sibling's headers, base, api, reasoning and retry never leak into a plane. +- **Contract:** + - the `describe` graft deep-equals the file's entries; + - the Models store's join shows the planes as configured, and a key typed in lands under the user's `apiKeyEnv`; + - `hasProviderApiKey`; + - parity with pi-ai's real `apply` on a route id that is not a plane. +- **Mock gateway:** `llm.stream` through the real `LlmRuntime` for every model × plane × protocol, including Go Gemini and a catalog-unknown id. It checks: + - the path, the body shape, the credential header and `x-opencode-session`; + - the `legacy` family; + - the shaping of a route-level `baseURL`. +- **Binary e2e, offline:** the S0 matrix. +- **Live e2e:** + - the retargeted `protocol-routing` and `dispatch-levels` suites; + - the "on" wire value per protocol for one toggle model on each plane. + +**P-R2** + +- **Live e2e:** the keyless matrix, keyed probes per family, and one keyless free-model probe. +- **Unit:** the default family. + +**P3** + +- **Unit:** + - the wrapper's merge; + - `NO_DISCOVERY`. +- **Real host:** + - discovery while pi-ai restarts; + - a composer with no `models` list. +- **Contract:** the wrapper is seen through the gateway and survives a restart. +- **Binary e2e:** the Settings fetch action. + +## End-to-end infrastructure + +### The `e2e-host` job (`ci.yml`) + +**When it runs:** on dispatch, nightly, and on PRs that touch `src/host/**`, `src/lifecycle.ts`, `src/identity.ts` or `cordis.patch.yml`. + +**What it does:** + +1. Installs `@deepseek-ai/dsh@$DSH_VERSION` into a throwaway prefix, cached by host version, with npm `overrides` forcing pi. +2. Asserts the pi version that resolved. +3. Builds, and boots a temporary HOME whose profile links the checkout through a relative `link:`. +4. Runs `test/e2e/host/probe-plugin.mjs` together with `test/e2e/host-binary.e2e.ts`, which runs only when `DSH_HOST_E2E=1`. + +**Cells:** + +- (a) the pi the target harness tag pins, with its patch (1.3.0 today, D14'), on PRs; +- (b) whatever npm installs, nightly and on dispatch. + +### The live gateway job (`OPENCODE_API_KEY` / `OPENCODE_GO_API_KEY`) + +- **Every PR:** the keyless matrix. A 404 means the path is gone; a 400 "Model is unavailable" or a 410 means the model is gone. +- **PRs that touch `src/host/**`, and nightly:** minimal keyed probes, one per plane × protocol, with a small `max_tokens`. +- **Nightly and on dispatch:** the full keyed suites. + +## How each phase is built + +1. **A spec**, written from this document and the code. It names the files, the first tests and their exact assertions, and the verification commands. +2. **The implementation.** The first test goes red on the base, or green for a pin. Then the change is made, and the gate below runs. +3. **A review panel**, running three reviews in parallel: + - correctness and the host's contracts; + - whether the tests perform the behaviour, plus coverage; + - the repository's rules, the docs and this plan. + + Two independent skeptics check each finding. Fixes repeat until no blocker or high finding survives. + +4. **The PR.** + - It is stacked on the previous phase, under a Conventional title. + - It names the rows it flips, and ticks its status row here. + - CI failures are fixed on that branch, then merged up the stack with a merge commit, never a rebase. + +## Verification (every phase) + +Run on Node 26 (`.node-version`): + +- `pnpm run check`: 0 errors, and no more warnings than before; +- `pnpm run test`, `test:shuffle` on three or more seeds, and `test:mock`; +- `pnpm run build` + `node --experimental-strip-types scripts/check.ts`; +- `pnpm run test:coverage` at or above the ratchet. + +Then on Node 24: `test:shuffle` + `test:mock`. + +Before each commit: + +- grep the diff and `lib/` for local paths; +- check that no secret or key value appears in a log, fixture or snapshot. + +For P-R1 and P3, the binary e2e (both pi cells) and the live keyless matrix must be green. The PR body records: + +- the measured endpoints; +- the `describe` graft; +- the handback; +- the save of a catalog-unknown id. diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index c33f0d05..5a366b38 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -4,15 +4,15 @@ How this plugin makes OpenCode's models speak the protocol each of them is actua ## 1. Why routing exists -OpenCode serves its models on **four different API shapes**, and which one a model lives on is a property of the model, not of the account. The per-plane counts, and the tables that go with them, live in [`redirect-planes.md`](./redirect-planes.md) §2; what follows is why the plugin has to care. Measured 2026-10-17: +OpenCode serves its models on **four different API shapes**, and which one a model lives on is a property of the model, not of the account. The per-plane counts, and the tables that go with them, live in [`redirect-planes.md`](./redirect-planes.md) §2; what follows is why the plugin has to care. Wire endpoints measured 2026-10-17; counts as of 2026-10-31 (the shipped shim, regenerated in #132): | API shape | Wire endpoint | Which models | Signal in the catalog | | :-- | :-- | :-- | :-- | -| OpenAI **Chat Completions** | `POST /zen/v1/chat/completions` | **the default**: 23 of 80 | the model names **no** SDK | -| OpenAI **Responses** | `POST /zen/v1/responses` | 31 of 80 | `@ai-sdk/openai` | -| Anthropic **Messages** | `POST /zen/v1/messages` | 17 of 80 | `@ai-sdk/anthropic` | -| Google **Generative AI** | `POST /zen/v1/models/:streamGenerateContent` | 8 of 80 | `@ai-sdk/google` | -| Mistral **Conversations** | `POST /zen/v1/chat/completions` | 1 of 80 | `@ai-sdk/mistral` | +| OpenAI **Chat Completions** | `POST /zen/v1/chat/completions` | **the default**: 24 of 81 | the model names **no** SDK | +| OpenAI **Responses** | `POST /zen/v1/responses` | 31 of 81 | `@ai-sdk/openai` | +| Anthropic **Messages** | `POST /zen/v1/messages` | 17 of 81 | `@ai-sdk/anthropic` | +| Google **Generative AI** | `POST /zen/v1/models/:streamGenerateContent` | 8 of 81 | `@ai-sdk/google` | +| Mistral **Conversations** | `POST /zen/v1/chat/completions` | 1 of 81 | `@ai-sdk/mistral` | Counts are the shipped catalog. They are asserted against this table by `catalog.test.ts`, because a count written into prose does not regenerate with the data it counts. @@ -168,7 +168,7 @@ Two properties matter more than completeness: Cold start is never empty: the bundled shim answers before the first refresh, so the picker has models immediately, and a refresh replaces the set rather than appending to it twice. A missing price is its own answer: when models.dev has no row for a model, the meter shows the model's name and `—` for the rate rather than `Free`, and the panel never falls back to describing the _previous_ model. -Both generated files are checked in CI: `catalog:shim` and `limits:shim` exit non-zero when the vendor moved, which is the only way to notice that models.dev changed under you. Regenerating one is a three-step deploy: regenerate, rebuild the bundles, commit both. The host loads `lib/index.mjs`, not `src/`. +Both generated files are checked in CI: `catalog:shim` and `limits:shim` exit non-zero when the vendor moved, which is the only way to notice that models.dev changed under you. A pull request is gated on that only when its own diff touches the generator or a module the generator loads (`scripts/freshness-scope.ts` decides); any other pull request, and a push to main, warns, and the nightly job proposes the refresh. Regenerating one is a three-step deploy: regenerate, rebuild the bundles, commit both. The host loads `lib/index.mjs`, not `src/`. ## 4. The UI diff --git a/docs/quota-meter.md b/docs/quota-meter.md index f66bcb61..d84864e6 100644 --- a/docs/quota-meter.md +++ b/docs/quota-meter.md @@ -41,10 +41,10 @@ Hovering lists **all three**, because the trigger can print one number and the n │ ──────────────────────────────────────────── │ │ │ │ Monthly allowance │ -│ mimo-v2.6-pro Go $60 · Plus $120 │ +│ mimo-v2.6-flash Go $60 · Plus $120 │ │ │ │ Session spend │ -│ mimo-v2.6-pro · $0.15/M $1.44 │ +│ mimo-v2.6-flash · $0.14 / $0.28 per 1M $1.44 │ │ │ │ Available Zen balance ⓘ Ready │ │ Zen balance is ready and will take over │ @@ -114,7 +114,7 @@ Go usage Healthy ● 5-hour 0% used · resets in 4h 58m 100% left ● Weekly 28% used · resets in 3d 2h 72% left ● Monthly 14% used · resets Nov 7, 8:55 AM 86% left -Monthly allowance mimo-v2.6-pro · Go $60 · Plus $120 +Monthly allowance mimo-v2.6-flash · Go $60 · Plus $120 OpenCode publishes only a percentage per window and no balance endpoint, so "left" is the inverse of the same percentage rather than a dollar figure; the plan tier cannot be probed either. ``` diff --git a/docs/quota-meter.zh-CN.md b/docs/quota-meter.zh-CN.md index d72ffe1d..ef13cd89 100644 --- a/docs/quota-meter.zh-CN.md +++ b/docs/quota-meter.zh-CN.md @@ -41,10 +41,10 @@ composer 输入框右侧的小控件。它只回答一个问题:**你当前所 │ ──────────────────────────────────────────── │ │ │ │ 月度额度 │ -│ mimo-v2.6-pro Go $60 · Plus $120 │ +│ mimo-v2.6-flash Go $60 · Plus $120 │ │ │ │ 当前会话消耗 │ -│ mimo-v2.6-pro · $0.15/M $1.44 │ +│ mimo-v2.6-flash · $0.14 / $0.28 per 1M $1.44 │ │ │ │ 可用 Zen 余额 ⓘ Ready │ │ Zen 余额已就绪,将在额度用尽时自动承接 │ @@ -113,7 +113,7 @@ Go 用量 状态正常 ● 5 小时 0% 已用 · 4小时58分钟后重置 100% 剩余 ● 每周 28% 已用 · 3天2小时后重置 72% 剩余 ● 每月 14% 已用 · 11月7日8点55分重置 86% 剩余 -月度额度 mimo-v2.6-pro · Go $60 · Plus $120 +月度额度 mimo-v2.6-flash · Go $60 · Plus $120 OpenCode 每个窗口只提供百分比、没有余额接口,所以「剩余」是同一百分比的反面,而不是美元数;套餐档位也无法探测。 ``` diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 164c30e3..3526ffaa 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -10,10 +10,12 @@ A **plane** is one gateway base URL with its own catalog and its own credential. | Plane | Route id | Base URL | Credential | Models | | :-- | :-- | :-- | :-- | --: | -| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | `OPENCODE_API_KEY` | 80 served / 130 listed | +| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | `OPENCODE_API_KEY` | 81 served / 131 listed | | Go (subscription) | `opencode-go` | the `endpoint` family's | `OPENCODE_GO_API_KEY` | 30 served / 37 listed | -**There is no longer one base URL per plane to quote.** The gateway answers on two endpoint families and the `endpoint` setting picks one: `auto` follows the catalog, which today names `…/inference/openai/v1` and `…/inference/go/openai/v1`, and `legacy` pins the older `…/zen/v1` and `…/zen/go/v1`. Both are still served and they are entitled separately, which is why the choice is the user's rather than a migration. The dated measurement below was taken on the legacy family. +Counts as of 2026-11-03 (the shipped shim, regenerated in #132 and refreshed for `pulse-alpha-free`); the document's 2026-10-09 stamp is for its measurements. + +**There is no longer one base URL per plane to quote.** The gateway answers on two endpoint families and the `endpoint` setting picks one: `auto`, the default, is an alias of `modern`, the per-protocol inference family (`…/inference/openai/v1` and `…/inference/go/openai/v1` for completions), and `legacy` pins the older `…/zen/v1` and `…/zen/go/v1`. Both are still served and they are entitled separately, which is why the choice is the user's rather than a migration. The dated measurement below was taken on the legacy family. A plane is a property of the **account and the base URL**, never of a model. The same model id can exist on both planes and be served on a different shape in each. @@ -21,21 +23,21 @@ A plane is a property of the **account and the base URL**, never of a model. The ## 2. Shapes -A **shape** is one wire protocol, named by the SDK a model declares in the catalog (`provider.npm`). A model naming no SDK speaks its route's own protocol, which is OpenAI Chat Completions. +A **shape** is one wire protocol, named by the SDK a model declares in the catalog (`provider.npm`). A model naming no SDK speaks its route's own protocol, which is OpenAI Chat Completions. Counts as of 2026-10-31 (the shipped shim, regenerated in #132). | SDK in the catalog | Protocol | Zen | Go | | :------------------ | :---------------------- | --: | ----: | -| _(none)_ | `openai-completions` | 23 | 19 | +| _(none)_ | `openai-completions` | 24 | 19 | | `@ai-sdk/openai` | `openai-responses` | 31 | **5** | | `@ai-sdk/anthropic` | `anthropic-messages` | 17 | **5** | | `@ai-sdk/mistral` | `mistral-conversations` | 1 | — | | `@ai-sdk/google` | `google-generative-ai` | 8 | **1** | -**Absence is the signal, and it is a real one.** Both planes set a provider-level `npm` of `@ai-sdk/openai-compatible` in `models.dev`, and **no model declares it explicitly**: 0 of 80 on Zen, 0 of 30 on Go. A model that speaks the completions shape simply omits `provider.npm` and inherits it. So "no SDK" does not mean "unknown"; it means _the provider default_, which is the protocol the route was itself registered with. +**Absence is the signal, and it is a real one.** Both planes set a provider-level `npm` of `@ai-sdk/openai-compatible` in `models.dev`, and **no model declares it explicitly**: 0 of 81 on Zen, 0 of 30 on Go. A model that speaks the completions shape simply omits `provider.npm` and inherits it. So "no SDK" does not mean "unknown"; it means _the provider default_, which is the protocol the route was itself registered with. That is why the servability check treats `undefined` as servable, and why `@ai-sdk/openai-compatible` is deliberately **not** in `PROTOCOL_FOR_SDK`: listing it would record the default as though it were an override. The coverage guard still covers the case where a model declares it explicitly. None do today, and if one did, the test would name it rather than drop the model. -**The two bold cells were the gap, and they are closed.** The Go plane serves 12 models on shapes other than completions; both planes now have a route for every shape either one carries. What made them work was making the plane part of a route's definition, below. +**The two bold cells were the gap, and they are closed.** The Go plane serves 11 models on shapes other than completions; both planes now have a route for every shape either one carries. What made them work was making the plane part of a route's definition, below. ## 3. Routes diff --git a/docs/testing.md b/docs/testing.md index c9d8c373..65ca32f9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -21,6 +21,12 @@ The bulk of the suite. Each file names one subject, and the cases are the branch The rule for what belongs here, from `AGENTS.md`: **a predicate that lives in a closure cannot be exercised alone**. Every decision the plugin makes is either exported or takes its value as a parameter, and `test/architecture.test.ts` polices the purity of `src/utils/` so the ones that should be pure stay pure. +Three properties the unit and mock suites keep by construction rather than by care: + +- **Offline.** `test/support/offline.ts` refuses every non-loopback request, so a networked runner cannot make a result depend on the vendor's live catalog. A case that means to fetch stubs `fetch` itself. +- **Order-independent.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test, and CI runs the suite shuffled (`pnpm run test:shuffle`; reproduce a failure with `--sequence.seed=`). A new module-level store gets a reset beside its siblings. +- **Engine-checked.** The suite refuses to start below `engines.node` and names both versions (`test/support/node-engine.ts`), because below the floor `node:sqlite` and the ICU strings the tests pin differ, and the failures read like regressions. + ### 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`). 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. @@ -31,20 +37,23 @@ The rule for what belongs here, from `AGENTS.md`: **a predicate that lives in a **Three rules, learned from failures:** -1. **A live case skips on a vendor-side move, it does not fail.** A retired model (`410 ModelDeprecated`), a closed tier (`FreeTierError`), a missing entitlement and a busy gateway (`429`) are all "not observable today". The reason is printed so the run says which. -2. **A case that walks candidates walks past the retired ones.** A `410` is an answer, so a picker that accepts "anything but a refusal" picks a dead model and then fails on it. +1. **A live case skips on a vendor-side move, it does not fail.** A retired model (`410 ModelDeprecated`, or an upstream's `400 … Model is unavailable`), a closed tier (`FreeTierError`), a missing entitlement and a busy gateway (`429`) are all "not observable today". The reason is printed so the run says which. +2. **A case that walks candidates walks past the retired ones.** A `410` is an answer, so a picker that accepts "anything but a refusal" picks a dead model and then fails on it. For a `400` it is the body, not the status, that makes it a retirement: a bare `400` is also a malformed request, which is the plugin's fault and must still fail (`test/e2e/verdict.ts`, `retiredAtTheGateway`). 3. **An assertion about the vendor is a claim about a moving thing.** Prefer asserting the PLUGIN's behaviour (which endpoint it chose, which headers it sent) over the vendor's answer. ## The fixtures -`test/test-helpers.ts` — streams, a capture fetch, a mock cordis context, the OpenCode store fixture, element walkers for the React trees. +`test/test-helpers.ts` — streams, a capture fetch, a mock cordis context, element walkers for the React trees. The jsdom (client) tests import it, so nothing in it may need a host-only module. + +`test/support/opencode-store-fixture.ts` — the OpenCode store fixture. It needs `node:sqlite` and the filesystem, which is why it lives with the other host-only support files rather than in the shared helpers. -`test/fixtures/catalogs.ts` — **synthetic catalogue rows**. Every other fixture quotes something that exists; this one builds the row a test needs, including rows the vendor has not produced. It exists because the plane-aware decisions are only observable when the two planes DISAGREE, and the real catalogue agrees about the free flag for every model it shares. +`test/support/catalogs.ts` — **synthetic catalogue rows**, reaching `src` through the one door like the other fixtures. Every other fixture quotes something that exists; this one builds the row a test needs, including rows the vendor has not produced. It exists because the plane-aware decisions are only observable when the two planes DISAGREE, and the real catalogue agrees about the free flag for every model it shares. -Two entry points: +Three entry points: - `spec(...)` / `divergent(id, { go, zen })` build a row (or a pair of rows) directly, for a decision that takes the row as its parameter — `coreToolDecision`, `calculateTurnCost`. -- `payload({ go, zen })` builds a whole `models.dev` payload, for the paths that read the LIVE catalogue through `refreshCatalog` (`test/plane-lookup.test.ts`). That world is per FILE: `refreshCatalog` merges by id and cannot remove, so a file that installs one holds nothing else. +- `payload({ go, zen })` builds a whole `models.dev` payload, for the paths that read the LIVE catalogue through `refreshCatalog` (`test/plane-lookup.test.ts`). That world is per TEST: `refreshCatalog` merges by id and cannot remove, but the setup file resets the live catalog after every test, so a file installs its world in `beforeEach` and gives its rows ids no other test names. +- `seedDivergentCatalog()` installs one such world for the request-path tests: `ZEN_FREE_PROBE` is free on Zen and paid on Go, `GO_FREE_PROBE` the reverse, so a lookup that reads the wrong plane is red in both directions (`test/tool-fallback.test.ts`, `test/fetch-patch.test.ts`). **A model with no price in a fixture is PAID at a nominal rate, not free.** The parser derives `is_free` from a zero cost, so a "no price" default would silently turn every fixture model into a free tier. @@ -55,7 +64,7 @@ A gate is a script, and a script that runs at import time cannot be tested — s | script | the decision, extracted | what the test holds | | :-- | :-- | :-- | | `generate-go-limits.ts` | `scripts/go-limits-html.ts` | which table is an allowance (by header, not position), the refusal when the page carries none, and the round trip through the real parser | -| `check-vendor-version.ts` | `scripts/vendor-version.ts` | the constant is found, and "read nothing" is distinguishable from "passed" | +| `check-vendor-version.ts` | `scripts/vendor-version.ts` | the constant is found, "read nothing" is distinguishable from "passed", and the scan reads every file a copy can live in but never the generated tables | | `publish-scoped.ts` | `scripts/wrapper-forwarder.ts` | the retired alias's entry LINKS against the built barrel in a real Node process | The pattern is the same one the `utils/` gate enforces inside `src/`, applied to the one place it had not reached: **a decision that lives in a top-level-await script is exercised only by the vendor's schedule**. Two of the three above had already passed while wrong — the version gate twice, the Go-limits reader when the docs moved to HTML. diff --git a/package.json b/package.json index 6eb0f7ff..82b45fdf 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "test:coverage": "vp test run --coverage", "test:e2e": "vp test --config vitest.e2e.config.ts", "test:mock": "vp test --config vitest.mock.config.ts", + "test:shuffle": "vp test run --sequence.shuffle", "typecheck": "tsc --noEmit", "cli:version": "node --experimental-strip-types scripts/check-vendor-version.ts", "version:patch": "vp pm version patch --no-git-checks", @@ -106,6 +107,7 @@ "@types/react-dom": "18.3.7", "@types/semver": "^7.8.0", "@vitest/coverage-v8": "5.0.5", + "es-module-lexer": "2.3.2", "jsdom": "^30.1.3", "react": "18.3.1", "react-dom": "18.3.1", @@ -113,7 +115,8 @@ "typescript": "^7.0.2", "ultracite": "^8.1.0", "vite-plus": "catalog:", - "vitest": "5.0.5" + "vitest": "5.0.5", + "yaml": "2.9.1" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4 || >=4.0.5-0 <4.1.0 || >=4.0.6-0 <4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 519a9eab..e954bcdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: '@vitest/coverage-v8': specifier: 5.0.5 version: 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5) + es-module-lexer: + specifier: 2.3.2 + version: 2.3.2 jsdom: specifier: ^30.1.3 version: 30.1.3 @@ -242,6 +245,9 @@ importers: vitest: specifier: 5.0.5 version: 5.0.5(@types/node@26.6.6)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(jsdom@30.1.3) + yaml: + specifier: 2.9.1 + version: 2.9.1 packages: diff --git a/scripts/check-vendor-version.ts b/scripts/check-vendor-version.ts index 534bce9f..86fbc4e5 100644 --- a/scripts/check-vendor-version.ts +++ b/scripts/check-vendor-version.ts @@ -23,6 +23,16 @@ * The second version only scanned `opencode/`, which meant a bare copy * was invisible to it: it passed while three e2e comments were two patches * stale. + * + * `--copies` runs the second check alone and stops before the registry. That + * half is offline and reads only files the repository maintains (the generated + * tables, which hold vendor data, are skipped), so only the repository can + * break it, and CI gates every pull request on it. The first half follows the + * vendor, and gates only a pull request that touches the pin or this script + * (`scripts/freshness-scope.ts` decides). + * + * A failure prints one `error: ` line first (the detail after it), so a + * CI step that only warns can quote the cause rather than guess it. */ import { readdir, readFile } from "node:fs/promises"; @@ -32,11 +42,11 @@ import { CLI_LITERAL as LITERAL, disagreements, pinnedVersion, + scansFile, } from "./vendor-version.ts"; -/** Where a copy may appear. Extensions are listed because the scan is recursive. */ +/** Where a copy may appear; `scansFile` decides which files under them are read. */ const ROOTS = ["src", "test", "docs", "scripts", "README.md"]; -const EXTENSIONS = /\.(?:ts|tsx|md|json|ya?ml)$/; const CONSTANT_FILE = "src/identity.ts"; @@ -56,7 +66,7 @@ const literals = async (pattern: RegExp): Promise> => { } } catch { // Not a directory, so read it as a file when it is one we scan. - if (EXTENSIONS.test(path)) { + if (scansFile(path)) { record(path, await readFile(path, "utf8")); } } @@ -87,6 +97,12 @@ const main = async (): Promise => { `these copies do not say ${pinned}, which is what ${CONSTANT_FILE} declares:\n${detail}` ); } + if (process.argv.includes("--copies")) { + process.stdout.write( + `ok ${[...copies.values()].flat().length} copy/copies and ${[...mentions.values()].flat().length} bare mention/mentions say ${pinned}, as ${CONSTANT_FILE} does (registry not asked)\n` + ); + return; + } const response = await fetch("https://registry.npmjs.org/opencode-ai/latest"); if (!response.ok) { @@ -109,4 +125,13 @@ const main = async (): Promise => { ); }; -await main(); +try { + await main(); +} catch (error) { + // The cause, not a stack: every failure above is a sentence written for the + // reader, and the line a warning step quotes. + process.stderr.write( + `error: ${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; +} diff --git a/scripts/check.ts b/scripts/check.ts index 868f0bd5..69c45915 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -546,11 +546,23 @@ if (releaseYml.includes("scripts/publish-scoped.ts")) { * validates the reference before it can reach a credential lookup. The client * measured 132541 bytes against the old line. * + * The same 128 -> 132 KB raise was made, independently, when the + * store-inspection row's remote descriptor landed: the row had been calling a + * namespace the client never mounted, and the fix is the descriptor plus the + * whitelist parser its codec runs. Source, not a dependency — the client + * measured 132320 bytes against the old line, about 1.2 KB over, which is this + * guard's "raise it" case. + * + * Raised 132 -> 136 KB when those two raises met in one merge: each had been + * measured against a tree without the other, and together the client measured + * 135604 bytes, about 0.4 KB over. Nothing new arrived with the merge — it is + * the same two surfaces, counted once each. + * * That is the kind of growth the last four raises were: a surface the HOST * answers, not a dependency that should have stayed external — which is the case * this tripwire exists to catch, and which the ceiling is not raised for. */ -const CLIENT_BUDGET = 132 * 1024; +const CLIENT_BUDGET = 136 * 1024; const clientPath = join(ROOT, "lib/client.js"); if (existsSync(clientPath)) { const clientStat = statSync(clientPath); diff --git a/scripts/freshness-scope.ts b/scripts/freshness-scope.ts new file mode 100644 index 00000000..63dc6541 --- /dev/null +++ b/scripts/freshness-scope.ts @@ -0,0 +1,318 @@ +/** + * Which freshness checks a pull request is GATED on, and which it only REPORTS. + * + * The three freshness checks in CI's `catalog` job (`catalog:shim`, + * `limits:shim`, `cli:version`) compare something checked in against something + * the vendor publishes, and the vendor moves most days. A check that fails + * every open pull request on each move blames the pull request for the + * vendor's change, and a red that is never the reader's fault is one the + * reader learns to skip. + * + * What the gate exists for is an OMISSION: someone changes a generator (or the + * parser it calls) and forgets to regenerate. That is a fact about the pull + * request's own diff, so the diff decides: a check GATES when the diff touches + * one of its INPUTS, and REPORTS (a warning, exit 0) otherwise. + * + * The inputs are code, never output. A check's inputs are its script and every + * local module the script loads, transitively, read from the sources rather + * than listed by hand - so adding an import to a generator moves its inputs + * without anyone remembering to. The edges are the ones Node would load: types + * erased by Node's own stripper, specifiers read by a real lexer, so a + * type-only import is no edge and a string that only looks like an import is + * none either (`importSpecifiers`). The two generated files are never inputs: a + * pull request that only carries a refresh must report drift, not fail on it. + * A pull request that edits a generated file without touching what generates + * it is still named, in a warning (`handEditedOutputs`), because a hand-edit + * is forbidden (AGENTS.md) and should stay visible. + * + * Everything exported is a function of its arguments - the file reader is a + * parameter - so the decision is testable without a filesystem or a diff. The + * CLI at the bottom is the effect half: + * + * ```sh + * git diff --name-only HEAD^1 HEAD \ + * | node --experimental-strip-types --disable-warning=ExperimentalWarning \ + * scripts/freshness-scope.ts catalog:shim + * # prints "gate" or "report" on stdout; any hand-edit warning goes to stderr + * ``` + * + * No network: it reads the changed-file list and the repository's sources. + */ + +import { readFileSync, realpathSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +import { dirname, join, normalize, resolve } from "node:path/posix"; +import { fileURLToPath } from "node:url"; + +import { initSync, parse } from "es-module-lexer"; + +/** The three checks, by the `package.json` script that runs each. */ +export type FreshnessCheck = "catalog:shim" | "cli:version" | "limits:shim"; + +/** What the diff decides for one check. */ +export type FreshnessScope = "gate" | "report"; + +/** One check: the script it runs, inputs it reads as data, and what it generates. */ +export interface FreshnessCheckSpec { + /** The script the check runs; the import walk starts here. */ + readonly entry: string; + /** + * Inputs the script reads as data rather than importing, so the import walk + * cannot see them. `check-vendor-version.ts` reads `src/identity.ts` as text + * and takes the pinned version out of it with `vendor-version.ts`'s + * `PINNED_VERSION` regular expression. + */ + readonly dataInputs: readonly string[]; + /** The generated files the check compares, and its `--write` rewrites. */ + readonly outputs: readonly string[]; +} + +/** + * Every generated file. Never an input, whichever import reaches it. + * `scripts/vendor-version.ts` keeps its own copy of this list + * (`GENERATED_TABLES`, which `scansFile` skips in the copy scan, since these + * hold vendor data, not copies the repository maintains) rather than importing + * it, which would make this module an input of `cli:version`; + * `test/freshness-scope.test.ts` ties the two. + */ +export const GENERATED_OUTPUTS: readonly string[] = [ + "src/catalog-data.ts", + "src/go-limits-data.ts", +]; + +export const FRESHNESS_CHECKS: Readonly< + Record +> = { + "catalog:shim": { + dataInputs: [], + entry: "scripts/regenerate-catalog-shim.ts", + outputs: ["src/catalog-data.ts"], + }, + "cli:version": { + dataInputs: ["src/identity.ts"], + entry: "scripts/check-vendor-version.ts", + outputs: [], + }, + "limits:shim": { + dataInputs: [], + entry: "scripts/generate-go-limits.ts", + outputs: ["src/go-limits-data.ts"], + }, +}; + +export const isFreshnessCheck = (value: string): value is FreshnessCheck => + Object.hasOwn(FRESHNESS_CHECKS, value); + +/** + * Every module specifier one source LOADS, as Node would load it: the source's + * types erased by Node's own stripper (`stripTypeScriptTypes`, the same + * erasure `--experimental-strip-types` applies when the generator runs), and + * the specifiers read off the result by a real lexer (`es-module-lexer`), not + * by a pattern. + * + * Both halves matter. The stripper decides what is type-only: a whole-statement + * `import type …` or `export type … from` is erased, so the module is never + * loaded and cannot change what the generator emits (`test/architecture.test.ts` + * skips these edges for the client graph on the same ground); an inline + * `import { type A }` strips to `import {}`, which still loads the module, so + * it IS an edge. The lexer knows strings, templates, regular expressions and + * comments: a `"docs/*"` string or a `/\/*$/u` regex cannot open a fake + * comment that hides the import after it, and an import written inside a + * template is text, not an edge. A hand-written pattern got both wrong, and a + * missed edge is the dangerous direction - it turns a gate into a report. + * + * Throws on a source Node itself could not load in strip mode (an `enum`, a + * `namespace`): every input runs under that flag, so such a file would break + * the generator first, and a throw fails the scope step closed rather than + * reporting a walk that stopped early. A type error in a skipped file is + * `pnpm run check`'s to catch, not freshness's. A dynamic `import()` whose + * argument is not a string literal names no file and is skipped. Only module + * source belongs here: `inputsOf` never hands it a JSON file or other data a + * generator imports, and names the file when this throws. + */ +export const importSpecifiers = (source: string): string[] => { + initSync(); + const [imports] = parse(stripTypeScriptTypes(source)); + return [ + ...new Set( + imports.flatMap((entry) => (entry.n === undefined ? [] : [entry.n])) + ), + ]; +}; + +/** + * The extensions Node parses as JavaScript or TypeScript modules. Any other + * local file a generator imports (`../package.json` with a JSON import + * attribute) is data: an input, but a leaf, never lexed. + */ +const MODULE_EXTENSION = /\.(?:[cm]?[jt]s|tsx)$/u; + +/** Reads one repository-relative path, or answers `undefined` when there is no such file. */ +export type SourceReader = (path: string) => string | undefined; + +/** + * The repository-relative file a LOCAL specifier names, from the importing + * file's own directory - or `undefined` for a package, a built-in, or a + * specifier that names no file in the tree. + */ +export const resolveLocalImport = ( + importer: string, + specifier: string, + read: SourceReader +): string | undefined => { + if (!specifier.startsWith("./") && !specifier.startsWith("../")) { + return undefined; + } + const base = normalize(join(dirname(importer), specifier)); + const candidates = [base, `${base}.ts`, `${base}.tsx`, `${base}/index.ts`]; + return candidates.find((candidate) => read(candidate) !== undefined); +}; + +/** + * A check's inputs: its entry, every local module the entry reaches through + * its imports, and its data inputs - minus the generated files, which are + * neither walked nor counted. A reached file that is not module source (a + * JSON import) counts as an input and is not walked further. Sorted, + * repository-relative. + * + * Throws when the entry cannot be read: an empty walk would answer "touches + * nothing", which reports a change that should have gated. Throws, naming the + * file, when a reached module cannot be stripped (`importSpecifiers`). + */ +export const inputsOf = ( + check: FreshnessCheck, + read: SourceReader +): string[] => { + const spec = FRESHNESS_CHECKS[check]; + if (read(spec.entry) === undefined) { + throw new Error( + `${check}: cannot read ${spec.entry} - the walk read nothing, which is not the same as no inputs` + ); + } + const generated = new Set(GENERATED_OUTPUTS); + const seen = new Set(); + const pending = [spec.entry]; + while (pending.length > 0) { + const file = pending.pop(); + if (file === undefined || seen.has(file) || generated.has(file)) { + continue; + } + seen.add(file); + if (!MODULE_EXTENSION.test(file)) { + continue; + } + let specifiers: string[]; + try { + specifiers = importSpecifiers(read(file) ?? ""); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${file}: ${message}`, { cause: error }); + } + for (const specifier of specifiers) { + const target = resolveLocalImport(file, specifier, read); + if (target !== undefined) { + pending.push(target); + } + } + } + for (const data of spec.dataInputs) { + if (!generated.has(data)) { + seen.add(data); + } + } + return [...seen].toSorted(); +}; + +/** One changed-file list, as `git diff --name-only` prints it. */ +export const parseChangedFiles = (text: string): string[] => + text + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line !== ""); + +/** GATE when the diff touches an input, REPORT otherwise. */ +export const scopeFor = ( + changedFiles: readonly string[], + inputs: readonly string[] +): FreshnessScope => { + const wanted = new Set(inputs); + return changedFiles.some((file) => wanted.has(file)) ? "gate" : "report"; +}; + +/** + * The check's generated files the diff changed while touching none of its + * inputs - a refresh, or a hand-edit; either way worth naming. Empty when the + * check gates: a generator change is expected to carry its regenerated output. + */ +export const handEditedOutputs = ( + check: FreshnessCheck, + changedFiles: readonly string[], + inputs: readonly string[] +): string[] => { + if (scopeFor(changedFiles, inputs) === "gate") { + return []; + } + const changed = new Set(changedFiles); + return FRESHNESS_CHECKS[check].outputs.filter((output) => + changed.has(output) + ); +}; + +/** The GitHub annotation that names one such file. */ +export const handEditWarning = ( + check: FreshnessCheck, + output: string +): string => + `::warning file=${output}::${output} changed, but nothing that generates it did (${FRESHNESS_CHECKS[check].entry} and its imports). It is generated: a refresh carries it, a hand-edit is forbidden (AGENTS.md). Regenerate with 'pnpm run ${check} -- --write' if this was not one.`; + +/** Reads a repository-relative path under `root`, or `undefined`. */ +const fileReader = + (root: string): SourceReader => + (path: string): string | undefined => { + try { + return readFileSync(resolve(root, path), "utf8"); + } catch { + return undefined; + } + }; + +const main = (): void => { + const check = process.argv[2] ?? ""; + if (!isFreshnessCheck(check)) { + process.stderr.write( + `usage: freshness-scope.ts <${Object.keys(FRESHNESS_CHECKS).join("|")}> < changed-files\n` + ); + process.exit(2); + } + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const changed = parseChangedFiles(readFileSync(0, "utf8")); + const inputs = inputsOf(check, fileReader(root)); + for (const output of handEditedOutputs(check, changed, inputs)) { + process.stderr.write(`${handEditWarning(check, output)}\n`); + } + process.stdout.write(`${scopeFor(changed, inputs)}\n`); +}; + +/** The real path of `path`, or `undefined` when there is none to resolve. */ +const realPath = (path: string | undefined): string | undefined => { + if (path === undefined) { + return undefined; + } + try { + return realpathSync(path); + } catch { + return undefined; + } +}; + +// Run only as a script, never on import (the tests import the functions above). +// Both sides are real paths: Node realpaths the main module's URL but only +// resolves `argv[1]`, so a plain comparison misses an absolute invocation +// through a symlink, and the CLI would print nothing and exit 0. +const invoked = realPath(process.argv[1]); +if ( + invoked !== undefined && + invoked === realPath(fileURLToPath(import.meta.url)) +) { + main(); +} diff --git a/scripts/generate-go-limits.ts b/scripts/generate-go-limits.ts index 9aad8f63..9ba69715 100644 --- a/scripts/generate-go-limits.ts +++ b/scripts/generate-go-limits.ts @@ -14,7 +14,10 @@ * A hand-typed table would therefore be stale within a release — and a stale * dollar figure is worse than none, because it looks authoritative. So the table * is generated from `https://opencode.ai/docs/go.md` (the page's own markdown - * source, served as `text/plain`) and CI fails when it drifts. + * source, served as `text/plain`), and CI checks it for drift: a pull request + * that changes this generator (or a module it loads) fails on a stale table; + * any other pull request, and a push to main, warns, and the nightly run + * proposes the refresh. * * **That URL is gone.** Measured 2026-10-12: it answers 404, the docs live under * `/v2/docs/console/go/` now, and the new page serves HTML - the allowance figures diff --git a/scripts/vendor-version.ts b/scripts/vendor-version.ts index a7f829ca..18207669 100644 --- a/scripts/vendor-version.ts +++ b/scripts/vendor-version.ts @@ -17,6 +17,36 @@ * @module scripts/vendor-version */ +/** + * The extensions a copy can live in. Listed because the scan is recursive and + * would otherwise read every file under its roots. + */ +export const SCANNED_EXTENSIONS = /\.(?:ts|tsx|md|json|ya?ml)$/; + +/** + * Never scanned: the generated tables hold the vendor's data, not copies the + * repository maintains, so a version-shaped string arriving in a refresh is not + * a stale copy - and scanning them would let a vendor move fail the gate that + * only the repository is supposed to be able to break. The same list as + * `GENERATED_OUTPUTS` in `scripts/freshness-scope.ts`, kept here rather than + * imported (an import would make that module an input of this check); + * `test/freshness-scope.test.ts` ties the two. + */ +export const GENERATED_TABLES: ReadonlySet = new Set([ + "src/catalog-data.ts", + "src/go-limits-data.ts", +]); + +/** + * Whether the copy scan reads a file. + * + * @param path - the file's path relative to the repository root, as the scan + * walks it (`src/identity.ts`, `README.md`). + * @returns whether the file can hold a copy the repository maintains. + */ +export const scansFile = (path: string): boolean => + SCANNED_EXTENSIONS.test(path) && !GENERATED_TABLES.has(path); + /** The constant every other copy must agree with, read from `src/identity.ts`. */ export const PINNED_VERSION = /OPENCODE_CLI_VERSION\s*=\s*"(\d+\.\d+\.\d+)"/; diff --git a/src/adapter-replacement.ts b/src/adapter-replacement.ts deleted file mode 100644 index 43201074..00000000 --- a/src/adapter-replacement.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Take a provider route over from the adapter that currently serves it. - * - * `llm.registerAdapter` refuses a route that already has an adapter - * (`DUPLICATE_ADAPTER`, `dsh-llm/lib/index.js:273`), which is why this plugin used - * to mount EXTRA routes for the protocols its models speak and redirect calls to - * them. That works, but it costs the thing a user notices: the extra routes are - * providers, so they must be hidden from every listing, and the models have to be - * merged back into the route the user actually configured. - * - * The registry itself is the way out, and it needs no unsupported internals: the - * duplicate check reads `adapters`, so REMOVING the current entry first makes the - * route registrable through the ordinary API. Our registration then carries a - * disposer and fires `llm/adapters-updated`, exactly like one the host made. - * - * @module adapter-replacement - */ - -import type { CordisContext } from "./cordis-context.ts"; - -/** A registration as the registry stores it: the adapter, plus its route. */ -interface RouteRegistration { - readonly adapter: Record; -} - -/** The part of the registry this module reads and writes. */ -interface Registry { - readonly adapters?: Map; - readonly registerAdapter?: ( - providers: readonly string[], - adapter: unknown - ) => unknown; -} - -/** - * Replace the adapter behind `routes` with one this plugin builds. - * - * The swap is two steps and the order matters: release the current entry, then - * register. A failure at either step restores what was there, so a composition - * that cannot take the route over is left exactly as it was rather than with the - * provider missing. - * - * @param ctx - the host context, for the registry and the logging. - * @param routes - the provider routes to take over. - * @param build - builds the adapter that will serve them. - * @returns a disposer that gives the routes back, or `undefined` when the - * composition cannot be taken over. - */ -export const replaceRouteAdapters = ( - ctx: CordisContext, - routes: readonly string[], - build: () => Record -): (() => void) | undefined => { - // The registry is read structurally: `adapters` is a field on the service, not - // part of its declared interface. - const { adapters, registerAdapter } = (ctx.llm ?? {}) as unknown as Registry; - if (!(adapters instanceof Map) || typeof registerAdapter !== "function") { - return undefined; - } - // Both halves are required: an adapter with no routes is refused by the host, - // and this module has nothing to say about a route nobody serves. - const present = routes.filter((route) => adapters.has(route)); - if (present.length === 0 || present.length !== routes.length) { - return undefined; - } - - // What was there, kept so the swap is reversible. Held by value: the registry - // entry is replaced, and the host's own effect still believes it holds it. - const previous = new Map(); - for (const route of present) { - const registration = adapters.get(route); - if (registration === undefined) { - return undefined; - } - previous.set(route, registration); - } - - const restore = (): void => { - for (const [route, registration] of previous) { - adapters.set(route, registration); - } - }; - - let adapter: Record; - try { - adapter = build(); - } catch (error) { - ctx.logger?.warn?.( - "[dsh-opencode-patch] could not build the replacement adapter; leaving the route with its own", - error - ); - return undefined; - } - - // Released only now, so a build failure never leaves the provider unserved. - for (const route of present) { - adapters.delete(route); - } - - let dispose: (() => void) | undefined; - try { - const handle: unknown = registerAdapter(present, adapter); - dispose = typeof handle === "function" ? (handle as () => void) : undefined; - } catch (error) { - restore(); - ctx.logger?.warn?.( - "[dsh-opencode-patch] the replacement adapter was refused; the route keeps its own", - error - ); - return undefined; - } - - return () => { - try { - dispose?.(); - } finally { - restore(); - } - }; -}; diff --git a/src/catalog-data.ts b/src/catalog-data.ts index f3436599..d611daf5 100644 --- a/src/catalog-data.ts +++ b/src/catalog-data.ts @@ -1854,6 +1854,21 @@ export const OPENCODE_ZEN_CATALOG: readonly CatalogModelSpec[] = [ }, ], }, + { + context_window: 1_048_576, + cost: { + cache_read: 0, + input: 0, + output: 0, + }, + id: "pulse-alpha-free", + input_modalities: ["text", "image"], + is_free: true, + max_output_tokens: 131_072, + name: "Pulse Alpha Free", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], + }, { context_window: 262_144, cost: { diff --git a/src/config-values.ts b/src/config-values.ts index 073084e5..c0093149 100644 --- a/src/config-values.ts +++ b/src/config-values.ts @@ -76,14 +76,10 @@ export const isKeySourcePolicy = (value: unknown): value is KeySourcePolicy => * The gateway answers on two families and the user, not the catalog, is the one * who knows which their account is entitled to: * - * - `auto` follows the catalog — the `api` the vendor's own entry names, which - * today is the `inference` family. - * - `legacy` forces the older pair, which really is one base per plane - * (`…/zen/v1`, `…/zen/go/v1`). - * - `modern` forces the newer `inference` family, whose base is PER PROTOCOL - * (`…/inference/openai/v1`, `…/inference/anthropic/v1`, - * `…/inference/google/v1beta`, and the `go/` variants) — so "inference/v1" is - * not an address the plugin ever sends, and the copy says `inference/…`. + * - `auto` — an alias of `modern` (the default). + * - `legacy` — the older single base per plane, `/zen/v1` and `/zen/go/v1`. + * - `modern` — the per-protocol `inference` family, + * `/inference/{openai,anthropic,google}/…` and `/inference/go/…` on Go. * * Declared here rather than in `config.ts` so the client card can read the same * list without importing schemastery. diff --git a/src/config.ts b/src/config.ts index 551fd3e1..cb300e89 100644 --- a/src/config.ts +++ b/src/config.ts @@ -214,8 +214,8 @@ export interface ResolvedPluginConfig { * Which endpoint family the gateway's requests go to. * * The two families are independently entitled and both are still served, so - * the choice is the user's: `auto` follows the catalog, and `legacy` / - * `modern` pin one. See {@link ENDPOINT_FAMILIES}. + * the choice is the user's: `auto` is an alias of `modern` (the default), and + * `legacy` pins the older single base per plane. See {@link ENDPOINT_FAMILIES}. */ endpoint: EndpointFamily; /** `*` when the core-tool fallback should run for every model. */ @@ -276,8 +276,8 @@ const resolvedKeySource = (raw: unknown): KeySourcePolicy => { * A row's endpoint family, or the default when it names something else. * * Lenient for the same reason {@link resolvedKeySource} is: a typo in a - * hand-edited row degrades to `auto`, which follows the catalog and is the - * answer that cannot be wrong about which family is current. + * hand-edited row degrades to `auto`, the default, so a typo cannot select a + * family the user never named. */ const resolvedEndpoint = (raw: unknown): EndpointFamily => { const value = readString(raw); @@ -563,10 +563,13 @@ export const Config = z.object({ .string() .default(CONFIG_DEFAULTS.endpoint) .description( - "Which endpoint family the gateway's requests go to. auto: follow the " + - "catalog's own address (today the inference family). legacy: force " + - "https://opencode.ai/zen/v1. modern: force " + - "https://opencode.ai/inference/. The two families are separately " + + "Which endpoint family the gateway's requests go to. modern: the " + + "per-protocol inference family, " + + "https://opencode.ai/inference/{openai,anthropic,google}/... on Zen " + + "and https://opencode.ai/inference/go/{openai,anthropic,google}/... " + + "on Go. auto: an alias of modern, and the default. legacy: the older " + + "single base per plane, https://opencode.ai/zen/v1 and " + + "https://opencode.ai/zen/go/v1. The two families are separately " + "entitled and both still answer, so this is a choice rather than a " + "migration; an unrecognised value falls back to auto." ) diff --git a/src/fetch-patch.ts b/src/fetch-patch.ts index fc11b3fe..f7c5d7b8 100644 --- a/src/fetch-patch.ts +++ b/src/fetch-patch.ts @@ -210,6 +210,25 @@ export const patchFetch = ( const headers = new Headers(headerSourceOf(input, init)); + // The body, read once, off whichever half carries it. DSH sends inference as + // fetch(new Request(url, { body })) — so init.body is undefined, and two + // things read it: the free-model check on the credential path below and the + // core-tool fallback after it. The free-model check used to read + // `init.body` alone, so for every DSH turn it answered "not free" and a + // keyless free model was reported as a missing credential. + let bodySource: "request" | "init" | "none" = "none"; + let bodyToPatch: RequestInit["body"] = init?.body; + if (bodyToPatch !== undefined) { + bodySource = "init"; + } else if (input instanceof Request) { + try { + bodyToPatch = await input.clone().text(); + bodySource = "request"; + } catch { + bodyToPatch = undefined; + } + } + // Capture the API key from the request headers so that quota monitoring, // usage display, and key resolution don't have to rely purely on static env vars. const extracted = extractApiKeyFromHeaders(headers); @@ -303,11 +322,9 @@ export const patchFetch = ( // an anonymous request anyway, so stripping it here would change nothing // but would also deprive a keyless-but-paid model of a header to fill. // - // `init.body` is read, not the `Request`: the tool-injection block below - // resolves a `Request`-carried body, and this check runs before it. A - // body the switch cannot see answers "not free", which is the safe + // A body the check cannot read answers "not free", which is the safe // side - the warning is the status quo, not a new failure. - !isFreeModelBody(init?.body, isGoPlaneUrl(url) ? "go" : "zen") + !isFreeModelBody(bodyToPatch, isGoPlaneUrl(url) ? "go" : "zen") ) { warnedNoCredential.add(plane); const sources: string[] = [ @@ -324,7 +341,7 @@ export const patchFetch = ( ]; console.warn( `[dsh-opencode-patch] no credential resolved for ${plane}; sending the adapter's placeholder, which the gateway answers with 401. Consulted: ${sources.join("; ")}.`, - "set a key on the route or its env var, or sign in through the settings card" + "set a key on the route or its env var, or turn on readOpencodeStore to use the key an OpenCode installation already holds" ); } } @@ -416,24 +433,8 @@ export const patchFetch = ( const newInit: RequestInit = { ...init, headers }; let requestToSend = input; - // 4. Core tool schema fallback for free-tier /responses models. - // - // DSH sends inference as fetch(new Request(url, { body })) — so init.body - // is undefined and the old `init?.body !== undefined` guard skipped the - // whole block: Agent Team turns left with no bash and the gateway 403s - // the body as non-OpenCode. Read the body off whichever half carries it. - let bodySource: "request" | "init" | "none" = "none"; - let bodyToPatch: RequestInit["body"] = init?.body; - if (bodyToPatch !== undefined) { - bodySource = "init"; - } else if (input instanceof Request) { - try { - bodyToPatch = await input.clone().text(); - bodySource = "request"; - } catch { - bodyToPatch = undefined; - } - } + // 4. Core tool schema fallback for free-tier /responses models, over the + // body read above. let finalBody = bodyToPatch; if (bodyToPatch !== undefined) { const newBody = maybeInjectCoreTools(url, bodyToPatch, headers, { diff --git a/src/go-discovery.ts b/src/go-discovery.ts index b4a0b750..eb7a098e 100644 --- a/src/go-discovery.ts +++ b/src/go-discovery.ts @@ -582,16 +582,12 @@ export const resolveRoutedKey = async ( ), }); - // Inverse precedence of `recordCapturedApiKey`: here the routed provider is - // authoritative, because the resolved key may be a shared or fallback - // credential whose prefix says nothing about the route in use. + // The routed provider decides, and the key's prefix never does: OpenCode + // issues `oc_sk_…` for Go credentials too (see `tierForRequest`). let tier: "go" | "zen" | "unknown" = "unknown"; - if (isGoRoute || (key !== undefined && key.startsWith("sk-"))) { + if (isGoRoute) { tier = "go"; - } else if ( - provider === "opencode" || - (key !== undefined && key.startsWith("oc_sk_")) - ) { + } else if (provider === "opencode") { tier = "zen"; } diff --git a/src/go-limits.ts b/src/go-limits.ts index d7d947bf..e00d8c55 100644 --- a/src/go-limits.ts +++ b/src/go-limits.ts @@ -20,9 +20,11 @@ * * So it is GENERATED from the vendor's own machine-readable doc * (`https://opencode.ai/docs/go.md`, which is the page's markdown source, not a - * scrape of rendered HTML) by `scripts/generate-go-limits.ts`, and CI fails when - * it drifts — the same arrangement as `catalog-data.ts` / models.dev, for the - * same reason. + * scrape of rendered HTML) by `scripts/generate-go-limits.ts`, and CI checks it + * for drift — the same arrangement as `catalog-data.ts` / models.dev, for the + * same reason: a pull request that changes the generator (or a module it + * loads) fails on a stale table; any other pull request, and a push to main, + * warns, and the nightly run proposes the refresh. * * ## What still cannot be shown, and why that is not an oversight * diff --git a/src/google-bridge.ts b/src/google-bridge.ts index d1d7c07a..8f04702d 100644 --- a/src/google-bridge.ts +++ b/src/google-bridge.ts @@ -411,8 +411,9 @@ const withArgonThinkingLevel = ( * one — a provider is a record pi dispatches through, not a class it * instantiates. `getModels` returns the models of the Zen plane whose SDK is * `@ai-sdk/google`, each carrying the base URL this protocol uses on that - * plane (`…/zen/v1`), so the request lands on - * `…/zen/v1/models/:streamGenerateContent`. + * plane (`…/inference/google/v1beta` under auto or modern, and + * `…/inference/go/google/v1beta` on Go), so the request lands on + * `…/inference/google/v1beta/models/:streamGenerateContent`. * * The streams are pi's raw implementation. The session, client, project and * User-Agent headers are NOT added here: the fetch patch already restores all @@ -420,8 +421,8 @@ const withArgonThinkingLevel = ( * append, so the bridge would only duplicate that work. * * @param specs - the catalogued models the route serves. - * @param family - the endpoint family to send them to; the catalog's own - * address by default. + * @param family - the endpoint family to send them to; auto (an alias of + * modern) by default. * @returns the provider the adapter's collection will hold. */ export const googleProviderFor = ( @@ -566,8 +567,8 @@ export interface GoogleBridgeInputs { */ readonly readOpencodeStore?: boolean; /** - * Which endpoint family the bridge's models are sent to. The catalog's own - * address by default, and the same choice the internal routes use so a + * Which endpoint family the bridge's models are sent to. Auto (an alias of + * modern) by default, and the same choice the internal routes use so a * deployment cannot end up on two families at once. */ readonly endpoint?: EndpointFamily; diff --git a/src/key-capture.ts b/src/key-capture.ts index f1aee1ac..5014edb4 100644 --- a/src/key-capture.ts +++ b/src/key-capture.ts @@ -54,33 +54,26 @@ export const isPlaceholderApiKey = (key: string): boolean => /** * Which account tier a request belongs to. * - * Ordered by how much each signal can be trusted, which is the whole point: + * Ordered by how much each signal can be trusted: * - * 1. The **key prefix** is intrinsic to the credential, so it survives a rotated - * key and a renamed provider route alike. - * 2. The **URL** is what the adapter actually called — observed, not configured. - * 3. The **provider route id** is only a hint. It is user-defined config and may + * 1. The **URL** is what the adapter actually called — observed, not configured. + * 2. The **provider route id** is only a hint. It is user-defined config and may * be named anything (`zen`, `oc`, `my-opencode`), so it must never be the sole * reason a lookup succeeds — that would make a captured, working key * invisible the moment the user renamed a route. * + * **The key's prefix is not a signal**, and it used to be the first one. It was + * measured wrong: OpenCode issues `oc_sk_…` for Go credentials too, and such a + * key answers the Go quota endpoint with real windows (`docs/engineering-notes.md`, + * "A credential prefix is not an entitlement"). Read first, it filed a working + * Go key under Zen — invisible to the meter, and injected into Zen requests. + * And `sk-…` is not OpenCode's to claim: an OpenAI key on a relay the + * deployment routes through here was filed as a Go key. + * * @param url - the request URL, when the caller has one. * @param provider - the routed provider id, when known. - * @param key - the credential itself, when known; its prefix is the best signal. */ -export const tierForRequest = ( - url?: string, - provider?: string, - key?: string -): KeyTier => { - if (typeof key === "string" && key.length > 0) { - if (key.startsWith("sk-")) { - return "go"; - } - if (key.startsWith("oc_sk_")) { - return "zen"; - } - } +export const tierForRequest = (url?: string, provider?: string): KeyTier => { if (typeof url === "string" && url.length > 0) { // Both endpoint families, because the gateway answers on both and a request // on either belongs to one of the two accounts. Matching only the legacy @@ -222,7 +215,7 @@ export const recordCapturedApiKey = ( // Deliberately NOT `resolveRoutedKey`'s precedence: a key seen on a live // request is classified by the request that carried it, not by the route the // user named. See `tierForRequest`. - const tier = tierForRequest(url, provider, key); + const tier = tierForRequest(url, provider); if (typeof provider === "string" && provider.length > 0) { capturedKeysByProvider.set(provider, { key, tier }); diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 85b3cb08..a024245d 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -95,12 +95,6 @@ const logRenameNotice = (ctx: CordisContext): void => { } }; -/** - * Register the Go quota service and its Typert remote face. - * - * Runs before the fetch patch so a composition without `globalThis.fetch` - * still gets a working meter. - */ /** * Publish a service through `ctx.provide`, when `ctx.plugin` is not available. * @@ -149,6 +143,12 @@ const mountByProvide = ( */ const usageMountedOn = new WeakSet(); +/** + * Register the Go quota service and its Typert remote face. + * + * Runs before the fetch patch so a composition without `globalThis.fetch` + * still gets a working meter. + */ const installUsageService = ( ctx: CordisContext, config: ResolvedPluginConfig @@ -274,14 +274,6 @@ const verifyUsageService = (ctx: CordisContext): void => { ); }; -/** - * Wrap `globalThis.fetch` so claimed OpenCode traffic carries session affinity - * and the gateway headers, restoring both on dispose. - * - * @returns `false` when there is no fetch to wrap. The caller then stops: the - * stream hook and discovery must not attach, or turns would run without the - * session affinity this plugin exists to provide. - */ /** * Roots that already carry the purge service, for the same reason the usage * service keeps its own set: `apply` runs once per live reload, and a service @@ -336,6 +328,14 @@ const installStoreService = (ctx: CordisContext): void => { } }; +/** + * Wrap `globalThis.fetch` so claimed OpenCode traffic carries session affinity + * and the gateway headers, restoring both on dispose. + * + * @returns `false` when there is no fetch to wrap. The caller then stops: the + * stream hook and discovery must not attach, or turns would run without the + * session affinity this plugin exists to provide. + */ const installFetchPatch = ( ctx: CordisContext, config: ResolvedPluginConfig, @@ -411,31 +411,7 @@ const installFetchPatch = ( // Own the Responses route from here, so the user configures nothing: they // keep the `opencode` provider and key they already have. Best-effort — a // route declared in the profile still works if this cannot register. - // The registration is async (it reads `llm-pi-ai` off the loader), so the - // disposer cannot live in a local the effect body fills in later: an effect - // that re-runs before the mount settles would find `undefined` here, release - // nothing, and leave the previous mount's routes registered — and the mount - // that then arrives would be an orphan owned by a disposed effect. So the - // effect owns the WITHDRAWAL, not the mount: a mount that completes after - // disposal is released on the spot, and one that completes before it is - // released by the disposer below. - let stopResponsesProvider: (() => void) | undefined; - let released = false; - const releaseResponsesProvider = (): void => { - released = true; - stopResponsesProvider?.(); - stopResponsesProvider = undefined; - }; - void (async () => { - // The resolved config rides in: the mount reads the endpoint family and - // the installation-store switch from it. - const stop = await registerResponsesProvider(ctx, config); - if (released) { - stop?.(); - return; - } - stopResponsesProvider = stop; - })(); + const releaseResponsesProvider = mountInternalRoutes(ctx, config); return () => { releaseResponsesProvider(); stopCatalogHiding?.(); @@ -460,6 +436,27 @@ const installStreamHook = ( ctx.on?.("llm/stream", createStreamHook(ctx, config, als), { prepend: true }); }; +/** + * The settings the LATEST activation on a root resolved, which is what every + * discovery callback answers from. + * + * Keyed on the root for the same reason the services are: `apply` runs once per + * live reload, and the host refuses a second registration under a namespace that + * is still held. The registrations therefore belong to the process, and only the + * settings they read move with each activation. + */ +const discoveryConfigOn = new WeakMap(); + +/** The namespaces this plugin publishes, and the catalog each one answers with. */ +const DISCOVERY_NAMESPACES: readonly { + readonly ns: string; + readonly catalog: () => readonly CatalogModelSpec[]; +}[] = [ + { catalog: () => [...getLiveGoCatalog(), ...getLiveZenCatalog()], ns: name }, + { catalog: getLiveGoCatalog, ns: "opencode-go" }, + { catalog: getLiveZenCatalog, ns: "opencode" }, +]; + /** * Publish the canonical Go/Zen catalog to DSH's model discovery. * @@ -467,73 +464,112 @@ const installStreamHook = ( * gateway listing enrichment, so both sit behind the one `enrichModels` * toggle: turning it off leaves DSH's own catalog and the raw gateway * listings untouched. + * + * Registered once per root and never withdrawn, like the usage service. The + * earlier shape registered on every activation and swallowed the host's + * duplicate refusal, so the FIRST activation's settings answered forever: a + * reload that changed `modelOverrides`, `maxContextWindow` or `enrichModels` + * changed nothing the picker saw. Each namespace is registered on its own, so + * one refusal no longer skips the others. */ const installModelDiscovery = ( ctx: CordisContext, config: ResolvedPluginConfig ): void => { - if (!config.enrichModels) { + const root: CordisContext = ctx.root ?? ctx; + const registered = discoveryConfigOn.has(root); + if (!registered && !config.enrichModels) { return; } - try { - const { llm } = ctx; - if (typeof llm?.registerModelDiscovery !== "function") { - return; + discoveryConfigOn.set(root, config); + if (registered) { + return; + } + const { llm } = ctx; + if (typeof llm?.registerModelDiscovery !== "function") { + return; + } + const current = (): ResolvedPluginConfig => + discoveryConfigOn.get(root) ?? config; + for (const { ns, catalog } of DISCOVERY_NAMESPACES) { + try { + llm.registerModelDiscovery(ns, () => { + const settings = current(); + return Promise.resolve( + settings.enrichModels + ? toDiscovered( + catalog(), + settings.maxContextWindow, + settings.modelOverrides + ) + : [] + ); + }); + } catch (error) { + // Non-fatal, and said: a picker missing a plane is otherwise a silence. + ctx.logger?.warn?.( + `[dsh-opencode-patch] model discovery for ${ns} did not register`, + error + ); } - llm.registerModelDiscovery(name, () => - Promise.resolve( - toDiscovered( - [...getLiveGoCatalog(), ...getLiveZenCatalog()], - config.maxContextWindow, - config.modelOverrides - ) - ) - ); - llm.registerModelDiscovery("opencode-go", () => - Promise.resolve( - toDiscovered( - getLiveGoCatalog(), - config.maxContextWindow, - config.modelOverrides - ) - ) - ); - llm.registerModelDiscovery("opencode", () => - Promise.resolve( - toDiscovered( - getLiveZenCatalog(), - config.maxContextWindow, - config.modelOverrides - ) - ) - ); - } catch { - // Model discovery registration is non-fatal } }; /** - * Plugin entry: register the quota service, patch `fetch` for OpenCode - * traffic, and capture turn state for configured providers. + * Mount the internal protocol routes for one activation, and own their withdrawal. * - * Order matters: - * 1. the rename notice, so it is the first thing a legacy row logs; - * 2. the quota service, before the fetch patch, so a composition without - * `globalThis.fetch` still gets a working meter; - * 3. the fetch patch, which bails out (warning) when there is nothing to - * wrap — `on` must not be attached in that case, or turns would run - * without session affinity; - * 4. the stream hook and model discovery, which only make sense once the - * request path is patched. + * The registration is async (it reads `llm-pi-ai` off the loader), so the + * disposer cannot live in a local the effect body fills in later: an effect + * that re-runs before the mount settles would find `undefined`, release nothing, + * and leave the previous mount's routes registered. So this owns the + * WITHDRAWAL, not the mount: a mount that completes after release is withdrawn + * on the spot, and one that completes before it is withdrawn by the release. * - * @param ctx - the Cordis host context. - * @param rawConfig - the row config, if any. + * A REJECTED mount is reported and contained. It used to escape as an unhandled + * rejection from a `void` IIFE — which Node's default mode turns into a process + * exit — whenever the Google bridge failed to register. + * + * @param ctx - the host context. + * @param config - the resolved settings the mount reads. + * @param register - the mount itself; a parameter so its failure is testable. + * @returns the release, safe to call at any point and more than once. */ +export const mountInternalRoutes = ( + ctx: CordisContext, + config: ResolvedPluginConfig, + register: typeof registerResponsesProvider = registerResponsesProvider +): (() => void) => { + let stop: (() => void) | undefined; + let released = false; + void (async () => { + let next: (() => void) | undefined; + try { + next = await register(ctx, config); + } catch (error) { + ctx.logger?.warn?.( + "[dsh-opencode-patch] the internal routes did not mount; routes declared in the profile are unaffected", + error + ); + return; + } + if (released) { + next?.(); + return; + } + stop = next; + })(); + return () => { + released = true; + stop?.(); + stop = undefined; + }; +}; + /** * Whether this plane holds a credential the gateway would accept. * - * Deliberately synchronous and deliberately generous: a declared env key, a key - * captured from a live request, or an org written by a completed login. The + * Deliberately synchronous and deliberately generous: a declared env key or a + * key captured from a live request. The * SQLite store is not consulted because it is opt-in and off by default, and * calling a deployment "keyless" when it does hold a credential would hide models * it can actually use. @@ -556,6 +592,23 @@ const hasPlaneCredential = (plane: string): boolean => { return getCapturedApiKey(plane) !== undefined; }; +/** + * Plugin entry: register the quota service, patch `fetch` for OpenCode + * traffic, and capture turn state for configured providers. + * + * Order matters: + * 1. the rename notice, so it is the first thing a legacy row logs; + * 2. the quota service, before the fetch patch, so a composition without + * `globalThis.fetch` still gets a working meter; + * 3. the fetch patch, which bails out (warning) when there is nothing to + * wrap — `on` must not be attached in that case, or turns would run + * without session affinity; + * 4. the stream hook and model discovery, which only make sense once the + * request path is patched. + * + * @param ctx - the Cordis host context. + * @param rawConfig - the row config, if any. + */ export const apply = ( ctx: CordisContext, rawConfig: PluginConfig = {} diff --git a/src/models-catalog.ts b/src/models-catalog.ts index 65418e82..00c3cc22 100644 --- a/src/models-catalog.ts +++ b/src/models-catalog.ts @@ -145,6 +145,25 @@ let refreshPromise: }> | undefined; +/** + * Put the catalog back to the bundled shim, as a fresh process holds it. + * + * The live maps, the retired-id sets and the refresh bookkeeping are module + * state for the life of the process, which is right for the host and wrong for + * a test: a case that refreshes from a fixture would otherwise decide what every + * later case in the file sees. The sibling resets are `clearCapturedApiKeys` and + * `clearSessionUsageStore`. + */ +export const resetCatalogState = (): void => { + activeGoCatalog = new Map(OPENCODE_GO_CATALOG.map((m) => [m.id, m])); + activeZenCatalog = new Map(OPENCODE_ZEN_CATALOG.map((m) => [m.id, m])); + deprecatedGoIds.clear(); + deprecatedZenIds.clear(); + lastRefreshedAt = 0; + lastRefreshSucceeded = false; + refreshPromise = undefined; +}; + export interface ParsedCatalogs { /** * The endpoint each plane's provider declares, read from the catalog itself @@ -722,7 +741,11 @@ export const enrichModelsResponse = async ( // If the upstream returned non-JSON, generate the full catalog response. } - const isGo = isGoModelsListingUrl(url); + // The plane from the PATH alone. The caller has already decided this is a + // listing, against the deployment's own gateway markers; asking + // `isGoModelsListingUrl` again applied the DEFAULT markers, so a relay's + // `/zen/go/v1/models` was answered with the Zen catalog. + const isGo = isGoPlaneUrl(url); const catalog = isGo ? getLiveGoCatalog(fetchFn) : getLiveZenCatalog(fetchFn); const catalogById = new Map(catalog.map((m) => [m.id, m])); const merged = new Map>(); diff --git a/src/models-discovery.ts b/src/models-discovery.ts index fa0ef70a..d5e4a731 100644 --- a/src/models-discovery.ts +++ b/src/models-discovery.ts @@ -451,7 +451,12 @@ export const hideResponsesRoute = ( return []; } const models = await Reflect.apply(originalListModels, this, [provider]); - if (!Array.isArray(models)) { + // **Only the routes this plugin claims.** A provider the deployment did + // not hand it — a direct `anthropic` or `openai` route — is answered by its + // own adapter and nothing else: the servability rule below reads OUR + // catalog, and against a foreign route it dropped real rows (a new Claude + // or GPT id has no OpenCode route, so it read as "unservable"). + if (!Array.isArray(models) || !config.providers.has(provider)) { return models; } // **An empty answer from a route that DECLARES models is a credential @@ -1072,6 +1077,13 @@ export const decorateModelInfo = ( model: string, signal?: AbortSignal ): Promise => { + // A provider this plugin does not claim is answered by its own adapter, + // unchanged and un-caught. Our catalog is OpenCode's: substituting it for a + // foreign route's failure, or overwriting a foreign route's limits with it, + // describes a model the user is not calling. + if (!config.providers.has(provider) && !isInternalRoute(provider)) { + return Reflect.apply(original, llm, [provider, model, signal]); + } let resolved: unknown; try { resolved = await Reflect.apply(original, llm, [provider, model, signal]); diff --git a/src/opencode-store-contract.ts b/src/opencode-store-contract.ts index 3a2b5de5..556bdcb3 100644 --- a/src/opencode-store-contract.ts +++ b/src/opencode-store-contract.ts @@ -6,6 +6,10 @@ * @module dsh-opencode-patch/opencode-store-contract */ +import type { TypertRemoteContribution } from "@deepseek-ai/dsh-typert-protocol"; + +import { isRecord } from "./utils/guards.ts"; + export const STORE_NAMESPACE = "opencodeStore"; /** Sanitized summary of an account found in the local OpenCode installation. */ @@ -34,3 +38,105 @@ export interface OpencodeStoreInspection { readonly message?: string; readonly path?: string; } + +const ACCOUNT_TYPES: ReadonlySet = new Set(["oauth", "key", "unknown"]); + +/** The string fields of an account summary the client may read. */ +const ACCOUNT_STRINGS = [ + "accountId", + "email", + "expiresAt", + "label", + "orgName", +] as const; + +/** + * Parse an account summary through a whitelist, or `undefined` when its shape + * is not one. Unknown fields are dropped rather than passed on: whatever the host + * adds later reaches the client only by being named here. + */ +const parseAccount = (value: unknown): OpencodeStoreAccount | undefined => { + if (!isRecord(value)) { + return undefined; + } + const raw = value; + if (typeof raw.type !== "string" || !ACCOUNT_TYPES.has(raw.type)) { + return undefined; + } + const account: Record = { type: raw.type }; + for (const field of ACCOUNT_STRINGS) { + if (typeof raw[field] === "string") { + account[field] = raw[field]; + } + } + for (const field of ["expired", "refreshable"] as const) { + if (typeof raw[field] === "boolean") { + account[field] = raw[field]; + } + } + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- built field by field from the whitelist above. + return account as unknown as OpencodeStoreAccount; +}; + +/** + * Parse an inspection answer, or `undefined` when it is not one. + * + * The same rule as the login contract's: a whitelist, so a field the host did + * not mean to send — a token, say — cannot reach the card by accident. + * + * @param value - what the remote answered. + * @returns the inspection, or `undefined`. + */ +export const parseStoreInspection = ( + value: unknown +): OpencodeStoreInspection | undefined => { + if (!isRecord(value)) { + return undefined; + } + const raw = value; + if (typeof raw.found !== "boolean") { + return undefined; + } + const account = + raw.account === undefined ? undefined : parseAccount(raw.account); + if (raw.account !== undefined && account === undefined) { + return undefined; + } + return { + found: raw.found, + ...(account === undefined ? {} : { account }), + ...(typeof raw.message === "string" ? { message: raw.message } : {}), + ...(typeof raw.path === "string" ? { path: raw.path } : {}), + }; +}; + +/** The wire codec for one inspection, in the shape `usageCodec` uses. */ +const storeInspectionCodec = { + create: () => ({ parse: parseStoreInspection }), + mode: "strict" as const, + schema: { parse: parseStoreInspection }, + typeSymbol: "dsh-opencode-patch#OpencodeStoreInspection", +}; + +/** + * The store-inspection namespace, as a contribution the client mounts. + * + * **Without it the card's inspection row could never answer.** A Remote is only + * reachable from the browser when a contribution carrying its descriptor was + * mounted on the client — the same finding `purgeRemote` records — and this one + * was not: the host mounted the service, and the row reported "unavailable". + */ +export const storeRemote: TypertRemoteContribution = { + descriptors: [ + { + id: "dsh-opencode-patch#opencodeStore/inspect", + invocation: { kind: "direct" }, + method: "inspect", + namespace: STORE_NAMESPACE, + parameters: [], + result: storeInspectionCodec, + service: STORE_NAMESPACE, + }, + ], + package: "dsh-opencode-patch", +}; diff --git a/src/opencode-store.ts b/src/opencode-store.ts index 3cb5347a..0028fc0f 100644 --- a/src/opencode-store.ts +++ b/src/opencode-store.ts @@ -136,10 +136,16 @@ export const readOpencodeCredentials = ( // sets it, and otherwise the legacy rows are read newest-first rather than // being dropped - which is the difference between this reader working and // answering nothing on a machine that does have credentials. - `SELECT integration_id AS integrationId, label, value FROM credential - WHERE active = 1 - OR (active IS NULL AND NOT EXISTS (SELECT 1 FROM credential WHERE active = 1)) - ORDER BY time_updated DESC` + // + // The fallback is PER INTEGRATION. Uncorrelated, one flagged `opencode-go` + // row hid every legacy `opencode` row, so the Zen key vanished from the + // reader while the inspector, which reads `opencode` alone, still said "ok". + `SELECT c.integration_id AS integrationId, c.label, c.value FROM credential c + WHERE c.active = 1 + OR (c.active IS NULL AND NOT EXISTS ( + SELECT 1 FROM credential f + WHERE f.active = 1 AND f.integration_id = c.integration_id)) + ORDER BY c.time_updated DESC` ); if (found === undefined) { return undefined; diff --git a/src/pi-provider.ts b/src/pi-provider.ts index 8baae6ad..558350de 100644 --- a/src/pi-provider.ts +++ b/src/pi-provider.ts @@ -80,10 +80,10 @@ export const LEGACY_PLANE_API: Readonly> = { /** * The base URL one plane's requests go to under one endpoint family. * - * `auto` is the catalog's own value, so following the vendor costs nothing here; - * `legacy` and `modern` pin the family explicitly. The two families are - * independently entitled, which is why a user can choose rather than being - * carried along by whatever the catalog happens to name. + * `auto` is an alias of `modern`: both answer the per-protocol inference + * family's plane base, and `legacy` pins the older single base per plane. The + * two families are independently entitled, which is why a user can choose + * rather than being carried along by whatever the catalog happens to name. * * @param plane - the gateway plane. * @param family - the endpoint family to resolve. @@ -198,8 +198,8 @@ const INFERENCE_BASE: Readonly< * * Memoised per family: the table is rebuilt at most once per family per process, * and the map keys are exactly the accepted {@link EndpointFamily} values, so an - * unknown family falls through to the catalog's own answer rather than to - * `undefined`. + * unknown family falls through to auto's answer (an alias of modern) rather + * than to `undefined`. */ const BASES_BY_FAMILY = new Map(); @@ -245,7 +245,7 @@ function withPlaneBase( type BaseTable = Readonly>>>; /** - * The base URL each protocol uses on each plane, following the catalog. + * The base URL each protocol uses on each plane under auto, an alias of modern. * * Kept as the default table for callers that name no family; read * {@link baseForProtocol} with an explicit family once one is known. @@ -293,7 +293,7 @@ const isPlane = (value: string): value is Plane => * * @param plane - the gateway plane. * @param protocol - the protocol the request speaks. - * @param family - the endpoint family; the catalog's own endpoint by default. + * @param family - the endpoint family; auto (an alias of modern) by default. * @returns the base URL, or undefined when the table does not carry that plane. */ export const baseForProtocol = ( @@ -406,7 +406,7 @@ const ALL_THINKING_LEVELS = [ * @param spec - the catalogued model. * @param protocol - the protocol it speaks, from {@link protocolFor}. * @param base - pi's installed catalog entry for this id, when it has one. - * @param family - the endpoint family to send it to; the catalog's own endpoint + * @param family - the endpoint family to send it to; auto (an alias of modern) * by default. * @returns the model object pi's provider dispatches on. */ diff --git a/src/responses-provider.ts b/src/responses-provider.ts index 643ed0b5..35119a82 100644 --- a/src/responses-provider.ts +++ b/src/responses-provider.ts @@ -1068,7 +1068,7 @@ export const registerResponsesProvider = async ( "[dsh-opencode-patch] internal routes are off (internalRoutes: false); a declared provider must serve each protocol" ); reportGaps(); - return undefined; + return bridgeDispose; } const wanted = Object.entries(ROUTE_FOR_PLANE_PROTOCOL).flatMap( ([plane, entry]) => @@ -1102,7 +1102,7 @@ export const registerResponsesProvider = async ( "[dsh-opencode-patch] every internal route is already declared; leaving them alone" ); reportGaps(); - return undefined; + return bridgeDispose; } const host = loadPiAi(ctx); if (host === undefined) { @@ -1110,7 +1110,7 @@ export const registerResponsesProvider = async ( "[dsh-opencode-patch] no loaded llm-pi-ai in this composition; internal routes stay unregistered" ); reportGaps(); - return undefined; + return bridgeDispose; } const providers: Record = {}; const mounted: string[] = []; @@ -1166,8 +1166,27 @@ export const registerResponsesProvider = async ( ); return bridgeDispose; } + // Set once the fiber exists, so every exit below can withdraw what the mount + // did register: a route captured before a startup error is live on the host, + // and a fiber nobody disposes keeps it there across every reload. + let fiber: ReturnType | undefined; + const releaseMount = (): void => { + for (const handle of captured) { + releaseRegistration(handle); + } + captured.length = 0; + const owned = fiber; + fiber = undefined; + void (async () => { + try { + await owned?.dispose?.(); + } catch { + // A disposal failure must not mask the plugin's own teardown. + } + })(); + }; try { - const fiber = scope.plugin(host, { providers }); + fiber = scope.plugin(host, { providers }); // Cordis's `Fiber` is not thenable; `await()` is what waits for the // lifecycle work and rethrows a startup error. A stand-in may hand over a // promise instead, so both shapes are settled here. @@ -1179,6 +1198,7 @@ export const registerResponsesProvider = async ( ctx.logger?.info?.( "[dsh-opencode-patch] the mount registered no route; internal routes stay unregistered" ); + releaseMount(); return bridgeDispose; } // The line that answers "what actually mounted", which the boot line's @@ -1191,19 +1211,10 @@ export const registerResponsesProvider = async ( return () => { bridgeDispose?.(); - for (const handle of captured) { - releaseRegistration(handle); - } - captured.length = 0; - void (async () => { - try { - await fiber?.dispose?.(); - } catch { - // A disposal failure must not mask the plugin's own teardown. - } - })(); + releaseMount(); }; } catch (error) { + releaseMount(); ctx.logger?.info?.( "[dsh-opencode-patch] internal routes stay unregistered (%s); routes declared in the profile are unaffected", error instanceof Error ? error.message : String(error) diff --git a/src/session-cost.ts b/src/session-cost.ts index d0086f64..082a1ad7 100644 --- a/src/session-cost.ts +++ b/src/session-cost.ts @@ -266,12 +266,20 @@ export const recordTurnUsage = ( turns: existing.turns + 1, }; + // Delete first, so the session moves to the END of the map. `Map.set` on an + // existing key keeps its original position, which made "the latest session" + // the most recently CREATED one: after sessions A then B, a new turn in A + // still read back as B's spend. + sessionStore.delete(sessionId); sessionStore.set(sessionId, updated); return toSnapshot(updated); }; -/** Read accumulated token and dollar usage for a session (or the latest active session). */ +/** + * Read accumulated token and dollar usage for a session, or for the session + * that most recently ran a turn when the id is absent or unknown. + */ export const getSessionUsage = ( sessionId?: string ): SessionUsageSnapshot | undefined => { diff --git a/src/settings-card.tsx b/src/settings-card.tsx index 773d4203..07d5a248 100644 --- a/src/settings-card.tsx +++ b/src/settings-card.tsx @@ -156,7 +156,7 @@ const PurgeRow: React.FC<{
= { groupQuota: "配额计量", groupRequests: "网关请求", endpoint: "API 端点", - endpointAuto: "自动(跟随目录)", + endpointAuto: "自动(同新端点)", endpointHint: - "请求发往哪个端点家族。自动会跟随网关目录给出的地址;另外两项则固定其一。旧端点与新端点的授权彼此独立且目前均可访问,因此请选择账户可用的那个,而不是当前最新的那个。", + "请求发往哪个端点家族。新端点按协议各自发往 inference 路径(inference/openai、inference/anthropic、inference/google;Go 为 inference/go/…);自动与新端点相同;旧端点固定使用较早的 zen/v1(Go 为 zen/go/v1)。两套端点的授权彼此独立且目前均可访问,因此请选择账户可用的那个。", endpointLegacy: "旧端点(zen/v1)", endpointModern: "新端点(inference/…)", freeModel: "免费", diff --git a/src/settings-fields.ts b/src/settings-fields.ts index dd6f19bd..7a6a6672 100644 --- a/src/settings-fields.ts +++ b/src/settings-fields.ts @@ -54,7 +54,16 @@ export const CONFIG_ONLY_FIELDS: readonly { }, { field: "debug", - reason: "diagnostic JSONL logging, not a behaviour a user tunes in the UI", + reason: + "diagnostic console logging of streamed calls, not a behaviour a user " + + "tunes in the UI", + }, + { + field: "debugFile", + reason: + "the JSONL file both debug halves append to; naming it writes the file " + + "whether or not debug is on, and a server-side path is a deployment fact " + + "rather than a card control", }, { field: "userAgent", diff --git a/src/settings-page.tsx b/src/settings-page.tsx index b6c9898c..aff7a838 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -15,6 +15,7 @@ import { } from "@deepseek-ai/dsh-client-ui-primitives"; import { DEFAULT_PROVIDERS, readStringList } from "./config-values.ts"; +import { storeRemote } from "./opencode-store-contract.ts"; import { purgeRemote } from "./purge-contract.ts"; import { OpencodeCard } from "./settings-card.tsx"; import { en, withBundleCopy, zh, type CopyKey } from "./settings-copy.ts"; @@ -51,14 +52,20 @@ export const PKG = "dsh-opencode-patch"; * * It is a contribution rather than a bare descriptor list because Typert * enforces unique package names, and this is the one place the package is - * registered. The meter's namespace is the only one this build mounts — the - * sign-in row and its `opencodeLogin` namespace are v2 work. + * registered. It carries every namespace the host mounts — the meter, the purge + * and the store inspection — and `test/remote-contract.test.ts` fails when a + * host service declares a method this list does not. The sign-in row and its + * `opencodeLogin` namespace are v2 work. */ export const patchRemote = { // ONE contribution, TWO namespaces. Typert enforces a unique package name per // contribution, so mounting them separately with the same package throws // "already registered" and drops the remote entirely. - descriptors: [...usageRemote.descriptors, ...purgeRemote.descriptors], + descriptors: [ + ...usageRemote.descriptors, + ...purgeRemote.descriptors, + ...storeRemote.descriptors, + ], package: PKG, }; diff --git a/src/settings-purge.ts b/src/settings-purge.ts index 07d34ac7..cb37bbc1 100644 --- a/src/settings-purge.ts +++ b/src/settings-purge.ts @@ -52,7 +52,8 @@ export const purgeDeprecatedModels = async ( // **The host's own failure comes first.** The host answers a failure as // `{ ok: false, … }`, and reading that as "not a purge result" replaced the // message that says WHY with a sentence about shape — which tells the reader - // nothing they can act on. + // nothing they can act on. The remote protocol sends `{ ok: false, error }` + // with `error` a serialized `RemoteError`, so its `message` is read too. if (isRecord(value) && value.ok === false) { const { error, message: said } = value; let message = "the host refused the purge"; @@ -60,6 +61,11 @@ export const purgeDeprecatedModels = async ( message = said; } else if (typeof error === "string") { message = error; + } else if (isRecord(error)) { + const { message: reason } = error; + if (typeof reason === "string") { + message = reason; + } } return { kind: "failed", message }; } diff --git a/src/settings-store-inspect.tsx b/src/settings-store-inspect.tsx index 31533df3..2e529c44 100644 --- a/src/settings-store-inspect.tsx +++ b/src/settings-store-inspect.tsx @@ -247,7 +247,7 @@ export const StoreInspectRow: React.FC = (props) => {
( + read: () => Promise, + attempts = 5, + sleep?: (attempt: number) => Promise, + live: () => boolean = () => true +): Promise => + retryUntilAnswered(read, { + attempts, + live, + sleep: + sleep ?? + backoffOver((callback, delayMs) => setTimeout(callback, delayMs)), + warn: (message, error) => { + console.warn(message, error); + }, + }); /** The container: reads once when the card appears, on window focus, or via manual refresh. */ export const UsageSummary: React.FC = ({ diff --git a/src/stream-hook.ts b/src/stream-hook.ts index 4b6ab25e..21aa046b 100644 --- a/src/stream-hook.ts +++ b/src/stream-hook.ts @@ -41,7 +41,6 @@ import { isAsyncIterableLike, isRecord } from "./utils/guards.ts"; interface StreamOptions { messages?: unknown; - prepared?: unknown; model?: unknown; parentId?: unknown; parentSession?: unknown; @@ -197,10 +196,6 @@ export const createStreamHook = ( // nothing about the response has to be rewritten. Returning here rather than // continuing means the redirected call does the session/debug/turn-state // work exactly once — its own pass through this hook. - // - // `prepared` is deliberately dropped. It is bound to the SOURCE route's - // adapter and already-resolved model, so re-resolving on the target route is - // not a loss — it is the only correct thing to do. const redirect = internalRouteFor( providerKey, options.model, @@ -255,7 +250,6 @@ export const createStreamHook = ( if (redirect === GO_GOOGLE_INTERNAL_ROUTE) { const stream = ctx.llm.stream({ ...options, - prepared: undefined, provider: redirect, messages: googleHistoryFor(options.messages, providerKey, redirect), }); @@ -349,11 +343,12 @@ export const createStreamHook = ( } const modelProp: unknown = options.model; const modelName = typeof modelProp === "string" ? modelProp : "unknown"; - // **The plane's own price, not the other plane's.** The two planes price the - // same id differently — `deepseek-v4.1-flash` is $0.30/$1.20 on Zen and - // $0.15/$0.60 on Go — and the plane-agnostic lookup searches Go FIRST, so a - // Zen turn was priced at the Go rate: half of what it cost. The plane - // decides, because the route is what the user configured. + // **The plane's own price, not the other plane's.** The two planes price + // some shared ids differently — one costs twice as much on Zen as on Go — + // and the plane-agnostic lookup searches Go FIRST, so a Zen turn was priced + // at the Go rate: half of what it cost. The plane decides, because the + // route is what the user configured; it is the same lookup the dispatch + // above makes. // // The fallback is for a model the plane's own catalog does not describe, // where any price is better than none — and the turn is still attributed to diff --git a/src/tool-fallback.ts b/src/tool-fallback.ts index 43d6f366..bcd25fc9 100644 --- a/src/tool-fallback.ts +++ b/src/tool-fallback.ts @@ -22,55 +22,6 @@ import { isGoPlaneUrl } from "./utils/request-shape.ts"; /** Path segment that marks a Responses-API body worth inspecting. */ export const RESPONSES_PATH = "/responses"; -/** Placeholder `read` schema formatted for Anthropic-compatible /messages. */ -export const DUMMY_READ_TOOL_ANTHROPIC = { - description: "Read a file or directory from the local filesystem.", - input_schema: { - properties: { - filePath: { - description: "The absolute path to the file or directory to read", - type: "string", - }, - offset: { - description: "The line number to start reading from (1-indexed)", - type: "number", - }, - limit: { - description: "The maximum number of lines to read (defaults to 2000)", - type: "number", - }, - }, - required: ["filePath"], - type: "object", - }, - name: "read", -} as const; - -/** Placeholder `bash` schema formatted for Anthropic-compatible /messages. */ -export const DUMMY_BASH_TOOL_ANTHROPIC = { - description: "Execute a bash command.", - input_schema: { - properties: { - command: { - description: "The command to execute", - type: "string", - }, - workdir: { - description: - "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.", - type: "string", - }, - timeout: { - description: "Optional timeout in milliseconds", - type: "number", - }, - }, - required: ["command"], - type: "object", - }, - name: "bash", -} as const; - /** Placeholder `read` schema the gateway's validator accepts. */ export const DUMMY_READ_TOOL = { description: "Read a file or directory from the local filesystem.", @@ -207,7 +158,7 @@ const toolNames = ( * * **Pure, so the row can be constructed.** The plane-aware lookup that produces * `row` cannot be exercised against a divergence the vendor has not produced - * yet — see `test/fixtures/catalogs.ts` — so the decision takes the row and the + * yet — see `test/support/catalogs.ts` — so the decision takes the row and the * lookup moves to the caller. * * @param row - the model's row on ITS plane, or `undefined` when that plane's @@ -278,7 +229,9 @@ export const maybeInjectCoreTools = ( return body; } // Both JSON routes: the gateway checks read+bash on /responses AND - // /chat/completions (a toolless completions body 403s the same way). + // /chat/completions (a toolless completions body 403s the same way). An + // Anthropic `/messages` URL names neither, so it never gets this far — which + // is why there are no Anthropic-dialect schemas below. const isResponses = url.includes(RESPONSES_PATH); const isCompletions = url.includes("/chat/completions") || url.includes("/completions"); @@ -331,36 +284,15 @@ export const maybeInjectCoreTools = ( tools = []; } const { hasBash, hasRead, isOpenAiFormat } = toolNames(tools); - const isAnthropic = url.includes("/messages"); - const useOpenAi = - isOpenAiFormat || - url.includes("/chat/completions") || - url.includes("/completions"); - - const getReadTool = () => { - if (isAnthropic) { - return DUMMY_READ_TOOL_ANTHROPIC; - } - if (useOpenAi) { - return DUMMY_READ_TOOL_FUNCTION; - } - return DUMMY_READ_TOOL; - }; - const getBashTool = () => { - if (isAnthropic) { - return DUMMY_BASH_TOOL_ANTHROPIC; - } - if (useOpenAi) { - return DUMMY_BASH_TOOL_FUNCTION; - } - return DUMMY_BASH_TOOL; - }; + const useOpenAi = isOpenAiFormat || isCompletions; + const readTool = useOpenAi ? DUMMY_READ_TOOL_FUNCTION : DUMMY_READ_TOOL; + const bashTool = useOpenAi ? DUMMY_BASH_TOOL_FUNCTION : DUMMY_BASH_TOOL; if (!hasRead) { - tools.push(getReadTool()); + tools.push(readTool); } if (!hasBash) { - tools.push(getBashTool()); + tools.push(bashTool); } parsed.tools = tools; const newBodyStr = JSON.stringify(parsed); diff --git a/src/usage-ui.ts b/src/usage-ui.ts index 727da1ca..b51358fe 100644 --- a/src/usage-ui.ts +++ b/src/usage-ui.ts @@ -687,7 +687,9 @@ const durationText = ( } const zh = locale?.startsWith("zh") === true; const units = zh - ? { d: "天", h: "小时", m: "分" } + ? // `分钟`, not `分`: the platform's narrow zh output, so a row reads the + // same on an engine without the constructor as on one with it. + { d: "天", h: "小时", m: "分钟" } : { d: "d", h: "h", m: "m" }; const out: string[] = []; if (parts.days !== undefined && parts.days > 0) { diff --git a/src/utils/retry.ts b/src/utils/retry.ts index 11a7c5e9..8dcaca05 100644 --- a/src/utils/retry.ts +++ b/src/utils/retry.ts @@ -2,11 +2,47 @@ * Generic retry loop over an asynchronous operation that may return undefined or throw. * * This is a pure algorithm: it takes the operation, the retry limit, the sleep - * mechanism and an optional liveness probe. No context, no globals, no I/O. + * between attempts, an optional liveness probe and an optional logger as + * parameters. No context, no globals, no I/O — and no clock: the caller owns the + * wait, and {@link backoffOver} builds the usual one over a timer the caller + * supplies. * * @module dsh-opencode-patch/retry */ +/** A `setTimeout`-shaped scheduler: run `callback` after `delayMs`. */ +export type RetryTimer = (callback: () => void, delayMs: number) => unknown; + +/** How {@link retryUntilAnswered} retries, and what it may touch. */ +export interface RetryOptions { + /** Maximum number of attempts (defaults to 5). */ + attempts?: number; + /** Polled before each attempt; returning false aborts. */ + live?: () => boolean; + /** + * Takes the attempt index (0-based) and resolves after the delay. Required, + * because a utility has no clock of its own: pass `backoffOver(timer)` for the + * default schedule. + */ + sleep: (attempt: number) => Promise; + /** Told when every attempt failed with an error; omitted means silent. */ + warn?: (message: string, error: unknown) => void; +} + +/** + * The usual schedule: a linear backoff of `baseMs` per attempt, on `timer`. + * + * @param timer - the scheduler the delay runs on. + * @param baseMs - the first delay; attempt `n` waits `baseMs * (n + 1)`. + * @returns a `sleep` for {@link RetryOptions}. + */ +export const backoffOver = + (timer: RetryTimer, baseMs = 300) => + (attempt: number): Promise => + new Promise((resolve) => { + timer(resolve, baseMs * (attempt + 1)); + }); + /** * Attempt to read a defined value until an answer arrives or attempts are exhausted. * @@ -18,20 +54,17 @@ * - If all attempts are exhausted, it returns `undefined`. * * @param read - the async reader operation. - * @param attempts - maximum number of attempts (defaults to 5). - * @param sleep - callback taking attempt index (0-based) and returning a sleep Promise. - * @param live - optional predicate polled before each attempt; returning false aborts. + * @param options - the attempt limit, the sleep, the liveness probe and the + * logger, all owned by the caller. * @returns the first defined answer, or `undefined` if unfulfilled. */ export const retryUntilAnswered = async ( read: () => Promise, - attempts = 5, - sleep: (attempt: number) => Promise = (attempt) => - new Promise((resolve) => { - setTimeout(resolve, 300 * (attempt + 1)); - }), - live: () => boolean = () => true + options: RetryOptions ): Promise => { + const attempts = options.attempts ?? 5; + const live = options.live ?? (() => true); + const { sleep } = options; let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt += 1) { if (!live()) { @@ -52,7 +85,10 @@ export const retryUntilAnswered = async ( } } if (lastError !== undefined) { - console.warn("[dsh-opencode-patch] retry: all attempts failed", lastError); + options.warn?.( + "[dsh-opencode-patch] retry: all attempts failed", + lastError + ); } return undefined; }; diff --git a/test/adapter-replacement.test.ts b/test/adapter-replacement.test.ts deleted file mode 100644 index d83ecada..00000000 --- a/test/adapter-replacement.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * `replaceRouteAdapters` — taking a provider route over from the adapter that - * currently serves it. - * - * The duplicate check in `dsh-llm` reads the registry map, so releasing the entry - * first makes the route registrable through the ordinary API. These cases pin the - * three things that must hold for that swap to be safe: the original is restored on - * disposal, a failed build leaves it untouched, and a refusal does not strand the - * provider. - */ -import { describe, expect, it, vi } from "vitest"; - -import { replaceRouteAdapters } from "../src/adapter-replacement.ts"; -import type { CordisContext } from "../src/index.ts"; - -interface FakeLlm { - adapters: Map }>; - registerAdapter: ( - providers: readonly string[], - adapter: unknown - ) => (() => void) | undefined; -} - -/** A registry with one built-in route, and the events a swap would cause. */ -const registryWith = (route: string) => { - const original = { adapter: { name: "builtin" } as Record }; - const adapters = new Map([[route, original]]); - const events: string[] = []; - const llm: FakeLlm = { - adapters, - registerAdapter: (providers, adapter) => { - for (const provider of providers) { - if (adapters.has(provider)) { - throw new Error("DUPLICATE_ADAPTER"); - } - } - for (const provider of providers) { - adapters.set(provider, { adapter: adapter as Record }); - } - events.push(`registered:${providers.join(",")}`); - return () => { - for (const provider of providers) { - adapters.delete(provider); - } - events.push(`disposed:${providers.join(",")}`); - }; - }, - }; - return { adapters, events, llm, original }; -}; - -describe("replaceRouteAdapters", () => { - it("releases the route, registers ours, and restores the original after", () => { - const { adapters, events, llm, original } = registryWith("opencode"); - const ctx = { llm, logger: { warn: () => {} } } as unknown as CordisContext; - const ours = { name: "ours" }; - - const stop = replaceRouteAdapters(ctx, ["opencode"], () => ours); - - expect(stop).toBeTypeOf("function"); - expect(adapters.get("opencode")?.adapter).toBe(ours); - expect(events).toEqual(["registered:opencode"]); - - stop?.(); - expect(adapters.get("opencode")).toBe(original); - expect(events).toEqual(["registered:opencode", "disposed:opencode"]); - }); - - it("does nothing when the route is not registered", () => { - // A composition without the provider has no route to take over, and inventing - // one would give the user a provider the deployment never declared. - const { adapters, events, llm } = registryWith("somewhere-else"); - const ctx = { llm, logger: { warn: () => {} } } as unknown as CordisContext; - expect( - replaceRouteAdapters(ctx, ["opencode"], () => ({ name: "ours" })) - ).toBeUndefined(); - expect(adapters.has("opencode")).toBe(false); - expect(events).toEqual([]); - }); - - it("leaves the route with its own adapter when the build fails", () => { - const { adapters, llm, original } = registryWith("opencode"); - const warn = vi.fn(); - const ctx = { llm, logger: { warn } } as unknown as CordisContext; - const stop = replaceRouteAdapters(ctx, ["opencode"], () => { - throw new Error("no pi-ai here"); - }); - expect(stop).toBeUndefined(); - expect(adapters.get("opencode")).toBe(original); - expect(warn).toHaveBeenCalled(); - }); - - it("restores the original when the host refuses the registration", () => { - const { adapters, llm, original } = registryWith("opencode"); - // A refusal AFTER the release: the provider would otherwise be left unserved. - llm.registerAdapter = () => { - throw new Error("refused"); - }; - const warn = vi.fn(); - const ctx = { llm, logger: { warn } } as unknown as CordisContext; - expect( - replaceRouteAdapters(ctx, ["opencode"], () => ({ name: "ours" })) - ).toBeUndefined(); - expect(adapters.get("opencode")).toBe(original); - expect(warn).toHaveBeenCalled(); - }); -}); - -describe("replaceRouteAdapters: the shapes it declines", () => { - const ctxWith = (llm: unknown) => - ({ llm, logger: { warn: vi.fn() } }) as unknown as CordisContext; - - it("declines a registry without a usable map or registerAdapter", () => { - // Both are read structurally off the service, so a composition that exposes - // one and not the other must answer "cannot take over" rather than throwing - // halfway through a swap. - expect( - replaceRouteAdapters(ctxWith({}), ["opencode"], () => ({})) - ).toBeUndefined(); - expect( - replaceRouteAdapters( - ctxWith({ adapters: {}, registerAdapter: () => {} }), - ["opencode"], - () => ({}) - ) - ).toBeUndefined(); - expect( - replaceRouteAdapters( - ctxWith({ adapters: new Map(), registerAdapter: "nope" }), - ["opencode"], - () => ({}) - ) - ).toBeUndefined(); - // A context with no `llm` at all is the same answer. - expect( - replaceRouteAdapters({} as CordisContext, ["opencode"], () => ({})) - ).toBeUndefined(); - }); - - it("declines when any route of the group is unserved", () => { - // All-or-nothing: replacing half a protocol group would leave the other - // half on the host's adapter, and the two would disagree about the route. - const { llm } = registryWith("opencode"); - expect( - replaceRouteAdapters(ctxWith(llm), ["opencode", "opencode-go"], () => ({ - name: "ours", - })) - ).toBeUndefined(); - }); - - it("declines an empty route list", () => { - const { llm } = registryWith("opencode"); - expect(replaceRouteAdapters(ctxWith(llm), [], () => ({}))).toBeUndefined(); - }); - - it("gives the routes back when the host returns no disposer", () => { - // `registerAdapter` answers a callable or nothing. A composition that - // answers nothing still has to be reversible, or the route is stranded on - // our adapter with no way back. - const original = { - adapter: { name: "builtin" } as Record, - }; - const adapters = new Map([["opencode", original]]); - const llm = { - adapters, - registerAdapter: (providers: readonly string[], adapter: unknown) => { - for (const provider of providers) { - adapters.set(provider, { - adapter: adapter as Record, - }); - } - // Falls off the end: one of the two shapes the host answers with is no - // disposer at all, and a composition that answers that still has to be - // reversible. - }, - }; - const dispose = replaceRouteAdapters(ctxWith(llm), ["opencode"], () => ({ - name: "ours", - })); - expect(dispose).toBeDefined(); - // Ours is in place... - expect(adapters.get("opencode")?.adapter.name).toBe("ours"); - dispose?.(); - // ...and the original is back even though the host gave us no disposer. - expect(adapters.get("opencode")).toBe(original); - }); - - it("restores the original even if its own disposer throws", () => { - // Disposal runs inside a `finally`, so a throwing disposer cannot leave the - // route without its adapter. - const original = { - adapter: { name: "builtin" } as Record, - }; - const adapters = new Map([["opencode", original]]); - const llm = { - adapters, - registerAdapter: (providers: readonly string[], adapter: unknown) => { - for (const provider of providers) { - adapters.set(provider, { - adapter: adapter as Record, - }); - } - return () => { - throw new Error("dispose exploded"); - }; - }, - }; - const dispose = replaceRouteAdapters(ctxWith(llm), ["opencode"], () => ({ - name: "ours", - })); - expect(() => dispose?.()).toThrow("dispose exploded"); - expect(adapters.get("opencode")).toBe(original); - }); -}); diff --git a/test/architecture.test.ts b/test/architecture.test.ts index 53c1f050..cfd06fd5 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -13,18 +13,38 @@ * * @module test/architecture.test */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, posix } from "node:path"; import { describe, expect, it } from "vitest"; -const UTILS_DIR = join(import.meta.dirname, "..", "src", "utils"); +const SRC_DIR = join(import.meta.dirname, "..", "src"); +const UTILS_DIR = join(SRC_DIR, "utils"); -/** Every `.ts` file directly inside `src/utils/`. */ -const utilityFiles = (): string[] => - readdirSync(UTILS_DIR) - .filter((name) => name.endsWith(".ts")) - .toSorted(); +/** + * Every `.ts`/`.tsx` file under `dir`, at any depth, relative to `dir`. + * + * Recursive on purpose: a utility in a subfolder is still a utility, and a walk + * that read one level would let `utils/nested/` hold anything at all. + */ +const filesUnder = (dir: string): string[] => { + const files: string[] = []; + const walk = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const full = join(current, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (/\.tsx?$/u.test(entry.name)) { + files.push(full.slice(dir.length + 1).replaceAll("\\", "/")); + } + } + }; + walk(dir); + return files.toSorted(); +}; + +/** Every utility, at any depth under `src/utils/`. */ +const utilityFiles = (): string[] => filesUnder(UTILS_DIR); /** The source with `//` and block comments removed, keeping line structure. */ const stripComments = (source: string): string => @@ -33,6 +53,44 @@ const stripComments = (source: string): string => .replaceAll(/^\s*\/\/.*$/gmu, "") .replaceAll(/\/\/.*$/gmu, ""); +/** + * Runtime imports of one file. A type-only import is erased by the compiler, + * so it is no edge in the bundle and is skipped; a mixed one is a real edge. + */ +const runtimeImportsOf = (source: string): string[] => { + const specifiers: string[] = []; + for (const match of source.matchAll( + /\b(?:import|export)\s+(type\s+)?(?:[^;"']*?\sfrom\s+)?"([^"]+)"/gu + )) { + if (match[1] === undefined && match[2] !== undefined) { + specifiers.push(match[2]); + } + } + return specifiers; +}; + +/** + * Every `src/` file `entry` reaches at runtime, entry included, mapped to its + * source with comments stripped. + */ +const runtimeGraph = (entry: string): Map => { + const seen = new Map(); + const visit = (file: string): void => { + if (seen.has(file)) { + return; + } + const source = stripComments(readFileSync(join(SRC_DIR, file), "utf8")); + seen.set(file, source); + for (const specifier of runtimeImportsOf(source)) { + if (specifier.startsWith("./") || specifier.startsWith("../")) { + visit(join(file, "..", specifier).replaceAll("\\", "/")); + } + } + }; + visit(entry); + return seen; +}; + /** The module specifiers one file imports. */ const importsOf = (source: string): string[] => { const specifiers: string[] = []; @@ -63,13 +121,29 @@ const FORBIDDEN: readonly { "a cordis context is the world: a utility takes its inputs as arguments instead", matches: (s) => s.startsWith("@deepseek-ai/cordis"), }, - { - because: - "only guards.ts and other utilities may be imported; anything else is an effect module", - matches: (s) => s.startsWith("../") || s.startsWith("./../"), - }, ]; +/** + * Why a relative import from the utility at `file` (a path relative to + * `src/utils/`) leaves the folder, or `undefined` when it stays inside. + * + * The specifier is resolved against the importing file's own directory, so a + * nested utility's `../guards.ts` is a sibling, not an escape. Comparing the raw + * specifier would flag every `../` — which, once the walk is recursive, rejects + * exactly the nested utilities the recursion admits. + */ +const escapesUtils = (file: string, specifier: string): string | undefined => { + if (!specifier.startsWith("./") && !specifier.startsWith("../")) { + return undefined; + } + // Resolved under a root named for the folder, so a path that leaves and + // re-enters it (`../../utils/guards.ts`) is judged by where it lands. + const target = posix.join("/utils", posix.dirname(file), specifier); + return target.startsWith("/utils/") + ? undefined + : "only guards.ts and other utilities may be imported; anything else is an effect module"; +}; + describe("architecture · a property read is a declared dependency", () => { const SRC = join(import.meta.dirname, "..", "src"); @@ -133,21 +207,7 @@ describe("architecture · a property read is a declared dependency", () => { return [...names]; }; - const sourceFiles = (): string[] => { - const files: string[] = []; - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (/\.tsx?$/u.test(entry.name)) { - files.push(full.slice(SRC.length + 1)); - } - } - }; - walk(SRC); - return files; - }; + const sourceFiles = (): string[] => filesUnder(SRC); it("reads no host service as a property except the declared `llm`", () => { // Cordis THROWS `cannot get property "X" without inject` for an undeclared @@ -170,7 +230,7 @@ describe("architecture · a property read is a declared dependency", () => { }); it("declares `llm`, the one service the plugin cannot work without", async () => { - const { inject } = await import("../src/identity.ts"); + const { inject } = await import("./support/src.ts"); expect(inject).toContain("llm"); }); }); @@ -217,21 +277,38 @@ describe("architecture · utils/ holds only pure functions", () => { for (const file of utilityFiles()) { const source = readFileSync(join(UTILS_DIR, file), "utf8"); for (const specifier of importsOf(source)) { - // `guards.ts` is dependency-free by construction and is the one - // non-utility a utility may share. - if (specifier === "./guards.ts") { - continue; - } for (const rule of FORBIDDEN) { if (rule.matches(specifier)) { violations.push(`${file} imports "${specifier}" — ${rule.because}`); } } + // `guards.ts` lives in utils/ and is dependency-free by construction, + // so it resolves inside the folder like any other utility. + const escape = escapesUtils(file, specifier); + if (escape !== undefined) { + violations.push(`${file} imports "${specifier}" — ${escape}`); + } } } expect(violations).toEqual([]); }); + it("resolves an import from the importing file's folder, at any depth", () => { + // Known answers, so the rule is seen to allow and to reject. + expect(escapesUtils("retry.ts", "./guards.ts")).toBeUndefined(); + expect(escapesUtils("nested/a.ts", "../guards.ts")).toBeUndefined(); + expect(escapesUtils("nested/a.ts", "./b.ts")).toBeUndefined(); + expect(escapesUtils("nested/a.ts", "../retry.ts")).toBeUndefined(); + expect(escapesUtils("retry.ts", "../config.ts")).toBeDefined(); + expect(escapesUtils("retry.ts", "./../config.ts")).toBeDefined(); + expect(escapesUtils("nested/a.ts", "../../config.ts")).toBeDefined(); + expect( + escapesUtils("nested/a.ts", "../../utils/guards.ts") + ).toBeUndefined(); + expect(escapesUtils("retry.ts", "../utils/guards.ts")).toBeUndefined(); + expect(escapesUtils("nested/a.ts", "../../utilsx/a.ts")).toBeDefined(); + }); + it("never reads process.env, a global the arguments could have carried", () => { // Comments are stripped first: prose ABOUT the environment is exactly what // a rule explaining itself looks like, and a rule that flags its own @@ -253,4 +330,259 @@ describe("architecture · utils/ holds only pure functions", () => { }); expect(violations).toEqual([]); }); + + /** + * A reference to the console or to a global timer, outside a comment — not + * only a call: `timer = setTimeout` as a default parameter, `backoffOver(setTimeout)` + * and `const log = console.warn` reach the global just as surely, and so do + * the four spellings of the global object (`globalThis.`, `window.`, + * `global.`, `self.`). A member of something else (`options.timer(`, + * `x.setTimeout(`) is not the global. + * + * A tripwire for the honest spelling, not a sandbox: an alias + * (`const g = globalThis; g.setTimeout(…)`) or bracket access on the global + * object gets past it, and catching those is a type-aware lint's job. + */ + const AMBIENT_EFFECT = + /(? { + // A log line is output and a timer is a clock: both are the world, and a + // utility that needs one takes it as a parameter, the way `retry.ts` takes + // its `sleep` and its `warn` and leaves the timer to the caller. + const violations: string[] = []; + for (const file of utilityFiles()) { + // Block comments are blanked rather than removed, so a line number in a + // violation is the line in the file. + const source = stripComments( + readFileSync(join(UTILS_DIR, file), "utf8").replaceAll( + /\/\*[\s\S]*?\*\//gu, + (comment) => comment.replaceAll(/[^\n]/gu, "") + ) + ); + for (const [index, line] of source.split("\n").entries()) { + const match = AMBIENT_EFFECT.exec(line); + if (match !== null) { + violations.push( + `${file}:${index + 1} — ${match[0].trim()} is an effect the caller owns` + ); + } + } + } + expect(violations).toEqual([]); + }); + + it("knows a console call and a timer when it sees one, and walks every level", () => { + // Known answers, so an empty violation list means the rule ran. + expect(AMBIENT_EFFECT.test('console.warn("x")')).toBe(true); + expect(AMBIENT_EFFECT.test("setTimeout(f, 1)")).toBe(true); + expect(AMBIENT_EFFECT.test("globalThis.setInterval(f, 1)")).toBe(true); + expect(AMBIENT_EFFECT.test("global.setTimeout(f, 1)")).toBe(true); + expect(AMBIENT_EFFECT.test("self.console.warn(x)")).toBe(true); + expect(AMBIENT_EFFECT.test("setImmediate(f)")).toBe(true); + expect(AMBIENT_EFFECT.test("clearTimeout(handle)")).toBe(true); + expect( + AMBIENT_EFFECT.test("(timer: RetryTimer = setTimeout, baseMs = 300) =>") + ).toBe(true); + expect(AMBIENT_EFFECT.test("const sleep = backoffOver(setTimeout);")).toBe( + true + ); + expect(AMBIENT_EFFECT.test("const log = console.warn;")).toBe(true); + expect(AMBIENT_EFFECT.test("const { warn } = console;")).toBe(true); + expect(AMBIENT_EFFECT.test('console["warn"](x)')).toBe(true); + expect(AMBIENT_EFFECT.test("(options.warn ?? console.warn)(m, e)")).toBe( + true + ); + expect(AMBIENT_EFFECT.test("options.timer(resolve, ms)")).toBe(false); + expect(AMBIENT_EFFECT.test("x.setTimeout(f, 1)")).toBe(false); + expect(AMBIENT_EFFECT.test("const consoleLike = 1;")).toBe(false); + expect( + AMBIENT_EFFECT.test( + "type RetryTimer = (callback: () => void, delayMs: number) => unknown" + ) + ).toBe(false); + expect(utilityFiles()).toContain("retry.ts"); + expect(filesUnder(SRC_DIR)).toContain("utils/retry.ts"); + }); +}); + +describe("architecture · the client bundle reaches nothing host-only", () => { + /** The client bundle's entry, as `vite.config.ts` names it. */ + const ENTRY = "settings-page.tsx"; + + /** Every `src/` file the entry reaches at runtime, entry included. */ + const clientGraph = (): Map => runtimeGraph(ENTRY); + + /** What a client module may not import, each with the reason it is host-only. */ + const HOST_ONLY: readonly { + readonly matches: (specifier: string) => boolean; + readonly because: string; + }[] = [ + { + because: "a node built-in does not exist in the browser", + matches: (s) => s.startsWith("node:"), + }, + { + because: + "the cordis runtime is the host's, and the client gets its own context", + matches: (s) => s === "@deepseek-ai/cordis", + }, + { + because: + "pi is loaded from the host's installation, never shipped to a page", + matches: (s) => s.startsWith("@earendil-works/pi-ai"), + }, + ]; + + it("walks a graph that actually contains the client modules", () => { + // A walker that resolved nothing would pass every rule below vacuously. + const graph = clientGraph(); + expect(graph.has(ENTRY)).toBe(true); + expect(graph.has("usage-pill.tsx")).toBe(true); + expect(graph.has("config-values.ts")).toBe(true); + // And it must not wander into the host half through a type-only import. + expect(graph.has("lifecycle.ts")).toBe(false); + }); + + it("imports no node built-in, no cordis runtime and no pi", () => { + const violations: string[] = []; + for (const [file, source] of clientGraph()) { + for (const specifier of runtimeImportsOf(source)) { + for (const rule of HOST_ONLY) { + if (rule.matches(specifier)) { + violations.push(`${file} imports "${specifier}" — ${rule.because}`); + } + } + } + } + expect(violations).toEqual([]); + }); + + it("never reads process.env, which the browser does not have", () => { + // The bundle test runs the built file in a VM, so a read inside a function + // that never runs there passes it. Reading the source closes that gap. + const violations = [...clientGraph()] + .filter(([, source]) => /\bprocess\.env\b/u.test(source)) + .map(([file]) => file); + expect(violations).toEqual([]); + }); + + it("flags a planted violation, so a pass means something", () => { + // The rule is run over a source string with a known answer. + const planted = + 'import { readFileSync } from "node:fs";\nimport type { X } from "node:os";'; + expect(runtimeImportsOf(planted)).toEqual(["node:fs"]); + expect(HOST_ONLY.some((rule) => rule.matches("node:fs"))).toBe(true); + }); +}); + +describe("architecture · the host bundle mutates no registry it does not own", () => { + /** The host half's composition root: everything `apply()` can run. */ + const ENTRY = "lifecycle.ts"; + + /** A write to an `adapters` map: the llm registry's private table. */ + const REGISTRY_WRITE = /\badapters\??\.(?:delete|set|clear)\s*\(/u; + + it("walks a graph that actually contains the host modules", () => { + const graph = runtimeGraph(ENTRY); + expect(graph.has("stream-hook.ts")).toBe(true); + expect(graph.has("responses-provider.ts")).toBe(true); + expect(graph.has("settings-page.tsx")).toBe(false); + expect(REGISTRY_WRITE.test('llm.adapters.delete("opencode")')).toBe(true); + expect(REGISTRY_WRITE.test('llm.adapters?.delete("opencode")')).toBe(true); + expect(REGISTRY_WRITE.test("this.adapters.get(route)")).toBe(false); + }); + + it("nothing reachable from lifecycle.ts deletes or sets an llm adapters entry", () => { + // `registerAdapter` is all-or-nothing and only the owner's handle moves a + // registration's routes. An entry deleted from outside leaves the owner + // believing it holds the route, and its next dispose deletes ours. The + // plugin keeps pi-ai off the planes through the config waterfall instead + // (docs/engineering-notes.md, "Replacing another plugin's adapter + // registration is not an API"). + const violations = [...runtimeGraph(ENTRY)] + .filter(([, source]) => REGISTRY_WRITE.test(source)) + .map(([file]) => file); + expect(violations).toEqual([]); + }); +}); + +describe("architecture · tests reach src through one door", () => { + const SUPPORT = join(import.meta.dirname, "support", "src.ts"); + + /** + * Whether `source` DECLARES `name` — an `export const|function|class|…` + * of it — rather than passing it on with `export { name } from "…"`. + */ + const declares = (source: string, name: string): boolean => + new RegExp( + String.raw`export (?:declare )?(?:abstract )?(?:async )?(?:const|let|function\*?|class|interface|type|enum) ${name}\b`, + "u" + ).test(source); + + /** Each `export { names } from "../../src/"` block, names unaliased. */ + const reExportBlocks = ( + source: string + ): { readonly target: string; readonly names: readonly string[] }[] => + [ + ...source.matchAll( + /^export (?:type )?\{([^}]*)\} from "\.\.\/\.\.\/src\/([^"]+)"/gmu + ), + ].map((match) => ({ + names: (match[1] ?? "") + .split(",") + .map((entry) => + entry + .trim() + .replace(/^type\s+/u, "") + .replace(/\s+as\s+[\w$]+$/u, "") + ) + .filter((entry) => entry !== ""), + target: match[2] ?? "", + })); + + it("knows a declaration from a pass-through when it sees one", () => { + // Known answers, so an empty miss list means the rule ran. + const config = readFileSync(join(SRC_DIR, "config.ts"), "utf8"); + expect(declares(config, "Config")).toBe(true); + expect(declares('export { Config } from "./config.ts";', "Config")).toBe( + false + ); + expect(declares("export type ConfigLike = 1;", "Config")).toBe(false); + expect( + reExportBlocks( + 'export { a, type B, c as d } from "../../src/x.ts";\nexport type { E } from "../../src/y.ts";' + ) + ).toEqual([ + { names: ["a", "B", "c"], target: "x.ts" }, + { names: ["E"], target: "y.ts" }, + ]); + }); + + it("test/support/src.ts re-exports each name from the module that declares it", () => { + // Re-exporting through `src/index.ts` would tie every test to the barrel + // P2 shrinks, which is the coupling this file exists to remove; and a name + // taken from a module that only passes it on breaks when P10a moves the + // module that really defines it. + const source = readFileSync(SUPPORT, "utf8"); + expect(source).not.toMatch(/src\/index\.ts/u); + const blocks = reExportBlocks(source); + // Every `from "../../src/…"` in the file is one of the parsed blocks, and + // there is one block per module, so the walk below is not vacuous. + expect(blocks.length).toBe( + source.match(/from "\.\.\/\.\.\/src\//gu)?.length ?? 0 + ); + expect(blocks.length).toBeGreaterThanOrEqual(40); + const missing = blocks + .map(({ target }) => target) + .filter((target) => !existsSync(join(SRC_DIR, target))); + expect(missing).toEqual([]); + const notDeclared = blocks.flatMap(({ names, target }) => { + const defining = readFileSync(join(SRC_DIR, target), "utf8"); + return names + .filter((name) => !declares(defining, name)) + .map((name) => `${name} is not declared in src/${target}`); + }); + expect(notDeclared).toEqual([]); + }); }); diff --git a/test/catalog.test.ts b/test/catalog.test.ts index 1a7d7c45..4e9932fa 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -12,6 +12,7 @@ import { readFile } from "node:fs/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; import { OPENCODE_FAMILY_API } from "../src/catalog-data.ts"; +import { GO_MODEL_LIMITS } from "../src/go-limits-data.ts"; import { OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, @@ -35,7 +36,7 @@ import { sanitizeModalities, type ActiveTurnState, } from "../src/index.ts"; -import { findModelSpec } from "../src/models-catalog.ts"; +import { findModelSpec, resetCatalogState } from "../src/models-catalog.ts"; import { UNSERVED_PLANE_SDKS } from "../src/responses-routes.ts"; import { createCaptureFetch, headerOf, SESSION_RE } from "./test-helpers.ts"; @@ -197,12 +198,12 @@ describe("OpenCode Model Catalog & Enrichment", () => { // replacing the whole set — so these counts are the cold-start picker, and a // silent drop here is a model nobody can pick. Regenerate with // `scripts/regenerate-catalog-shim.ts` rather than editing by hand. - expect(OPENCODE_ZEN_CATALOG.length).toBe(80); + expect(OPENCODE_ZEN_CATALOG.length).toBe(81); const freeModels = OPENCODE_ZEN_CATALOG.filter((m) => m.is_free === true); // Only the free tiers the registry still lists as active. The count is the // `is_free` flag the shim carries, NOT the `-free` suffix: `big-pickle` is // flagged and is not suffixed, so a suffix rule would have missed it. - expect(freeModels.length).toBe(8); + expect(freeModels.length).toBe(9); const ids = new Set(OPENCODE_ZEN_CATALOG.map((m) => m.id)); expect(ids.has("beam-free")).toBe(true); @@ -397,15 +398,22 @@ describe("OpenCode Model Catalog & Enrichment", () => { // The one status the catalog answers for is 404, because the Go plane's // modern base serves no `/models` route at all. for (const status of [401, 403, 429, 500, 502]) { + // The rate limit carries the header a caller backs off by; the + // passthrough must hand it on with the status. + const headers: Record = + status === 429 ? { "retry-after": "30" } : {}; const upstream = Response.json( { error: { message: "refused" } }, - { status } + { headers, status } ); const enriched = await enrichModelsResponse( "https://opencode.ai/inference/openai/v1/models", upstream ); expect(enriched.status, `status ${status}`).toBe(status); + expect(enriched.headers.get("retry-after"), `status ${status}`).toBe( + headers["retry-after"] ?? null + ); const body = (await enriched.json()) as { data?: unknown[] }; expect(body.data, `status ${status} must not carry a catalog`).toBe( undefined @@ -422,6 +430,28 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(filled.data.length).toBeGreaterThan(0); }); + it("fills a Zen-plane 404 with the Zen catalog, as it does the Go plane's", async () => { + // The 404 rule is not a Go special case: a Zen base that serves no + // listing is answered from the Zen catalog the same way. + const out = await enrichModelsResponse( + "https://opencode.ai/zen/v1/models", + new Response("Not Found", { status: 404 }) + ); + expect(out.status).toBe(200); + const body = (await out.json()) as { data: unknown[] }; + expect(body.data.length).toBe(getLiveZenCatalog().length); + }); + + it("still answers a 200 whose body is not JSON with the full catalog", async () => { + const out = await enrichModelsResponse( + "https://opencode.ai/zen/v1/models", + new Response("not json", { status: 200 }) + ); + expect(out.status).toBe(200); + const body = (await out.json()) as { data: unknown[] }; + expect(body.data.length).toBe(getLiveZenCatalog().length); + }); + it("answers the modern Go listing with the Go catalog, not the Zen one", async () => { // **This is the bug the Go plane's endpoint change exposed.** The Go plane's // base is the `inference` family, and the adapter lists its models at @@ -790,6 +820,29 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(seen[0]).not.toContain("models.dev"); }); + it("resetCatalogState puts a refreshed catalog back to the bundled shim", async () => { + // The per-test reset in `test/support/` relies on this, and a reset that + // restored nothing would let every catalog count depend on run order again. + await refreshCatalog( + (async () => + Response.json({ + "opencode-go": { + models: { "reset-probe-model": { name: "Reset Probe" } }, + }, + })) as unknown as typeof fetch, + true + ); + expect(getLiveGoCatalog().some((m) => m.id === "reset-probe-model")).toBe( + true + ); + resetCatalogState(); + expect(getLiveGoCatalog().some((m) => m.id === "reset-probe-model")).toBe( + false + ); + expect(getLiveGoCatalog()).toHaveLength(OPENCODE_GO_CATALOG.length); + expect(getLiveZenCatalog()).toHaveLength(OPENCODE_ZEN_CATALOG.length); + }); + it("refreshCatalog degrades gracefully on network errors without throwing", async () => { const before = { go: getLiveGoCatalog().length, @@ -1058,6 +1111,43 @@ describe("catalog · the README's routing table", () => { expect(served).toEqual([zen, go]); }); + it("holds every allowance figure in the docs to the generated table", async () => { + // The counts above are parsed, the dollar figures beside a model id were + // not, and the meter's mock-ups paired one model with another's allowance + // in four files while the README's own prose had the right one. Every + // ` Go $a · Plus $b` a document prints is read back against the table + // the meter itself prints from. + let seen = 0; + for (const file of [ + "../README.md", + "../README.zh-CN.md", + "../docs/quota-meter.md", + "../docs/quota-meter.zh-CN.md", + ]) { + const text = await readFile(new URL(file, import.meta.url), "utf8"); + const figures = [ + ...text.matchAll( + /([a-z0-9][a-z0-9.-]*)[\s·,`]+Go \$([\d.]+) · Plus \$([\d.]+)/gu + ), + ]; + expect( + figures.length, + `${file} prints no allowance figure to check` + ).toBeGreaterThan(0); + for (const [, id = "", go, plus] of figures) { + const row = GO_MODEL_LIMITS[id]; + expect(row, `${file} prints an allowance for ${id}`).toBeDefined(); + expect( + [Number(go), Number(plus)], + `${file}: the allowance it prints for ${id}` + ).toEqual([row?.go, row?.goPlus]); + seen += 1; + } + } + // A known answer, so a regex that stops matching cannot pass as clean. + expect(seen).toBeGreaterThanOrEqual(8); + }); + it("holds the plane-by-plane table to both catalogs", async () => { // The third document with counts, and the one whose table is per plane: // every cell is a count, and every cell was stale when this was written. diff --git a/test/config.test.ts b/test/config.test.ts index 6b2b1979..e4efd16d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -363,8 +363,8 @@ describe("Config schema", () => { it("accepts each endpoint family and degrades an unknown one to auto", () => { // Same rule as the key-source policy, for the same reason: the family is - // user-facing and hand-editable, and `auto` follows the catalog, so a typo - // degrades to the answer that cannot be wrong about which family is current. + // user-facing and hand-editable, so a typo degrades to `auto`, the default, + // rather than selecting a family the user never named. for (const family of ENDPOINT_FAMILIES) { expect(resolveConfig({ endpoint: family }).endpoint).toBe(family); } diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index 9d5b519f..ab933291 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -68,6 +68,7 @@ import { accessDisabled, freeTierClosed, gatewayBusy, + retiredAtTheGateway, usableAsSubject, } from "./verdict.ts"; @@ -312,14 +313,19 @@ describe.skipIf(!LIVE)("live protocol routing", () => { candidates: readonly string[], protocol: string, key?: string, - base: string = ZEN_BASE + base: string = ZEN_BASE, + // A case that asserts one particular answer narrows the pick to candidates + // that give it, so an otherwise observable answer of another kind (an + // unmetered 200 where the case asserts the free-tier gate) moves the walk on + // instead of being asserted on. + accept: (answer: Probe) => boolean = () => true ): Promise => { for (const model of candidates) { const answer = await probe(protocol, model, key, base); // The rule is in `verdict.ts`, where it is unit-tested: a refusal and a // retirement are both vendor-side facts, and a case built on either fails - // for a reason that is not its own. - if (usableAsSubject(answer)) { + // for a reason that is not its own. The case's own narrowing comes after. + if (usableAsSubject(answer) && accept(answer)) { return model; } } @@ -332,11 +338,19 @@ describe.skipIf(!LIVE)("live protocol routing", () => { // The vendor catalog no longer lists a free model on /responses, so the // free-tier routing case now asserts the COMPLETIONS side of the split. // The subject is picked live from the free slice, in catalog order, so - // whichever free model is alive today is the one tested. + // whichever GATED free model is alive today is the one tested: the case + // asserts the free-tier gate, so an unmetered model (which answers 200 + // everywhere) cannot be its subject. const candidates = OPENCODE_ZEN_CATALOG.filter((m) => m.is_free).map( (m) => m.id ); - const model = await pickModel(candidates, "openai-completions"); + const model = await pickModel( + candidates, + "openai-completions", + undefined, + ZEN_BASE, + freeTierClosed + ); if (model === undefined) { context.skip(); return; @@ -812,28 +826,36 @@ describe.skipIf(!LIVE)("live protocol routing", () => { servedProtocolFor(model) ?? "openai-completions", model ); - if (freeTierClosed(response)) { - continue; - } if (gatewayBusy(response)) { // The gateway answered, but about its own load. That is neither a // completion nor a dead tier, and it must not be reported as one. + // Asked before the refusals below, which count a 429 among them. busy = true; continue; } + // A gated, withdrawn or refused candidate says nothing about whether + // the free tier is reachable, so the walk moves on to the next one. + if ( + freeTierClosed(response) || + retiredAtTheGateway(response) || + accessDisabled(response) + ) { + continue; + } answered = { model, status: response.status }; break; } if (answered === undefined) { if (busy) { - // Every candidate was busy, which says nothing about the tier. + // No candidate answered and at least one was busy, which says + // nothing about the tier. context.skip(); return; } // Reported rather than skipped: the account (or the vendor) closed the // whole free tier, and that is worth a line in the run's output. console.log( - `[e2e] every free candidate answered FreeTierError — the free tier needs a session token this plugin does not hold; see docs/opencode-1.19-api.md. The header-restoration case did not run.` + `[e2e] no free candidate answered with a completion — each was gated (FreeTierError: the free tier needs a session token this plugin does not hold; see docs/opencode-1.19-api.md), withdrawn upstream, refused for the account (no funds, no entitlement), or never answered. The header-restoration case did not run.` ); return; } diff --git a/test/e2e/verdict.ts b/test/e2e/verdict.ts index a3ac75a3..68b7087f 100644 --- a/test/e2e/verdict.ts +++ b/test/e2e/verdict.ts @@ -74,6 +74,12 @@ export const accessDisabled = (result: Probe): boolean => * slice while the gateway answers `410 ModelDeprecated`. Measured: the registry * listed `gemini-3-flash` as active while the gateway refused it. * + * A third shape says the same thing about an upstream rather than the + * gateway's own entry: `400 Upstream request failed: Model is unavailable`. + * Measured on a free model the catalog still listed as active, answered + * identically on two runs a minute apart. The status alone is not enough — a + * `400` is also how a malformed request is refused — so the body has to say it. + * * Such a model is not a subject to assert on — the case would fail on a * retirement the plugin had nothing to do with. * @@ -81,7 +87,9 @@ export const accessDisabled = (result: Probe): boolean => * @returns whether the answer is a retirement. */ export const retiredAtTheGateway = (result: Probe): boolean => - result.status === 410 || result.summary.includes("ModelDeprecated"); + result.status === 410 || + result.summary.includes("ModelDeprecated") || + (result.status === 400 && result.summary.includes("Model is unavailable")); /** The gateway is busy: it answered, but not about the route. */ export const gatewayBusy = (result: Probe): boolean => result.status === 429; diff --git a/test/endpoint-copy.test.ts b/test/endpoint-copy.test.ts new file mode 100644 index 00000000..9388e11d --- /dev/null +++ b/test/endpoint-copy.test.ts @@ -0,0 +1,72 @@ +/** + * What the endpoint knob says it does, on every surface that says it. + * + * The schema description, the card's two locales and both READMEs described + * `modern` as `/inference/v1` — a base the code never sends. `modern` (and + * `auto`, which is the same table today) sends each protocol to its own + * inference path: `/inference/{openai,anthropic,google}/…`, under + * `/inference/go/…` on the Go plane. `legacy` is the older single base per + * plane. Copy is the only place a user learns that, so it is pinned. + * + * @module test/endpoint-copy.test + */ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { Config } from "../src/config.ts"; +import { en, zh } from "../src/settings-copy.ts"; + +/** The first line of a file that contains `marker`, or fail loudly. */ +const lineOf = async (file: string, marker: string): Promise => { + const text = await readFile(new URL(file, import.meta.url), "utf8"); + const line = text.split("\n").find((l) => l.includes(marker)); + if (line === undefined) { + throw new Error(`${file} has no line containing ${marker}`); + } + return line; +}; + +describe("endpoint copy", () => { + it("the endpoint copy names the per-protocol inference family and calls auto an alias of modern", async () => { + const schema = ( + Config as unknown as { + dict: { endpoint: { meta: { description: unknown } } }; + } + ).dict.endpoint.meta.description; + expect(typeof schema).toBe("string"); + const d = String(schema); + expect(d).not.toContain("inference/v1"); + expect(d).toContain("inference/{openai,anthropic,google}"); + expect(d).toContain("inference/go/{openai,anthropic,google}"); + expect(d).toMatch(/auto: an alias of modern/u); + + for (const copy of [en, zh]) { + const card = copy.endpointModern + copy.endpointAuto + copy.endpointHint; + expect(card).not.toContain("inference/v1"); + expect(card).toContain("inference/openai"); + // Legacy is one base per plane, and the Go plane's is not zen/v1. + expect(copy.endpointHint).toContain("zen/go/v1"); + } + expect(en.endpointAuto).toContain("same as Modern"); + expect(zh.endpointAuto).toContain("同新端点"); + + const row = await lineOf("../README.md", "API Endpoint"); + expect(row).not.toContain("inference/v1"); + expect(row).toContain("inference/{openai,anthropic,google}"); + expect(row).toContain("same as Modern"); + const zhRow = await lineOf("../README.zh-CN.md", "API 端点"); + expect(zhRow).not.toContain("inference/v1"); + expect(zhRow).toContain("inference/{openai,anthropic,google}"); + expect(zhRow).toContain("同新端点"); + + for (const [file, marker] of [ + ["../README.md", "**Which endpoint family.**"], + ["../README.zh-CN.md", "**发往哪个端点家族。**"], + ] as const) { + const paragraph = await lineOf(file, marker); + expect(paragraph, file).not.toContain("inference/v1"); + expect(paragraph, file).toContain("/inference/openai/v1"); + } + }); +}); diff --git a/test/fetch-patch.test.ts b/test/fetch-patch.test.ts index 73c5ddfd..66e697c2 100644 --- a/test/fetch-patch.test.ts +++ b/test/fetch-patch.test.ts @@ -10,6 +10,12 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + GO_FREE_PROBE, + seedDivergentCatalog, + ZEN_FREE_PROBE, +} from "./support/catalogs.ts"; +import { createOpencodeStore } from "./support/opencode-store-fixture.ts"; import { DUMMY_BASH_TOOL, DUMMY_READ_TOOL, @@ -18,15 +24,15 @@ import { SESSION_HEADER, clearCapturedApiKeys, getCapturedApiKey, + getLiveGoCatalog, recordCapturedApiKey, openCodeSessionIdFor, patchFetch, resolveConfig, type ActiveTurnState, -} from "../src/index.ts"; +} from "./support/src.ts"; import { createCaptureFetch, - createOpencodeStore, headerOf, parseJsonBody, toolNamesOf, @@ -91,6 +97,9 @@ describe("patchFetch", () => { "a key captured from a live request (policy auto): none" ); expect(line).toContain("not enabled"); + // The advice names a path this build has: there is no sign-in row here. + expect(line).not.toContain("sign in"); + expect(line).toContain("readOpencodeStore"); }); it("does NOT report a missing key on a plane that offers free models", async () => { @@ -137,6 +146,75 @@ describe("patchFetch", () => { ).toHaveLength(0); }); + it("reads the free model off a Request body, which is how DSH sends a turn", async () => { + // DSH calls fetch(new Request(url, { body })), so `init` carries no body. + // The check used to read `init.body` alone and reported every keyless free + // turn as a missing credential. + const als = new AsyncLocalStorage(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const ok = vi.fn(async () => Response.json({ ok: true })); + const patched = patchFetch( + ok as unknown as typeof fetch, + als, + resolveConfig(), + {} + ); + await patched( + new Request("https://opencode.ai/zen/v1/chat/completions", { + body: JSON.stringify({ messages: [], model: "beam-free" }), + method: "POST", + }) + ); + const freeLines = warn.mock.calls + .map((args) => args.map(String).join(" ")) + .filter((text) => text.includes("no credential resolved")); + expect(freeLines).toHaveLength(0); + + // The same keyless Request for a PAID model is still reported, so the + // case is not passing because the warning went quiet altogether. + await patched( + new Request("https://opencode.ai/zen/v1/chat/completions", { + body: JSON.stringify({ messages: [], model: "gpt-5.4" }), + method: "POST", + }) + ); + expect( + warn.mock.calls + .map((args) => args.map(String).join(" ")) + .some((text) => text.includes("no credential resolved for opencode")) + ).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + it("answers a relay's Go listing with the Go catalog", async () => { + // The listing is claimed against the deployment's own markers. The + // enrichment then re-asked with the DEFAULT markers, so a relay's Go + // listing was filled from the Zen catalog. + const als = new AsyncLocalStorage(); + const upstream = vi.fn(async () => Response.json({ data: [] })); + const patched = patchFetch( + upstream as unknown as typeof fetch, + als, + resolveConfig({ gatewayUrls: ["relay.example/zen"] }), + {} + ); + const response = await patched("https://relay.example/zen/go/v1/models", { + headers: { authorization: "Bearer test-go-key" }, + method: "GET", + }); + const body = (await response.json()) as { data: { id: string }[] }; + const ids = body.data.map((row) => row.id).toSorted(); + expect(ids.length).toBeGreaterThan(0); + expect(ids).toEqual( + getLiveGoCatalog() + .map((spec) => spec.id) + .toSorted() + ); + }); + it("does not name the sources when a credential does resolve", async () => { const als = new AsyncLocalStorage(); const warnings: unknown[][] = []; @@ -732,6 +810,100 @@ describe("patchFetch", () => { expect(names).toEqual([DUMMY_READ_TOOL.name, DUMMY_BASH_TOOL.name]); }); + it("classifies a free model by the plane the request is on", async () => { + // The catalog's free flag is per plane, and the two planes can disagree. + // The seeded probe is free on Zen and paid on Go, under a marker nothing + // matches, so only the plane's own row can admit it: a Zen request gains + // the schemas, the same body on the Go plane is left alone. + await seedDivergentCatalog(); + const als = new AsyncLocalStorage(); + const config = resolveConfig({ freeModelMarker: "none-of-these" }); + const send = async ( + provider: string, + url: string, + model = ZEN_FREE_PROBE + ): Promise => { + const { capture, mockFetch } = createCaptureFetch(); + const value = openCodeSessionIdFor(`plane-${provider}`); + await als.run({ provider, value }, async () => { + await patchFetch( + mockFetch, + als, + config + )(url, { + body: JSON.stringify({ input: "hi", model }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + return toolNamesOf(parseJsonBody(capture.init?.body)); + }; + + expect( + await send("opencode", "https://opencode.ai/zen/v1/responses") + ).toEqual(["read", "bash"]); + expect( + await send("opencode-go", "https://opencode.ai/zen/go/v1/responses") + ).toBeUndefined(); + // The reverse probe, so a Go-first lookup is red in both directions. + expect( + await send( + "opencode-go", + "https://opencode.ai/zen/go/v1/responses", + GO_FREE_PROBE + ) + ).toEqual(["read", "bash"]); + expect( + await send( + "opencode", + "https://opencode.ai/zen/v1/responses", + GO_FREE_PROBE + ) + ).toBeUndefined(); + }); + + it("reads the plane from the URL's path, never from the turn's route", async () => { + // A relay serves its Go plane under a path of the same shape, and the route + // id there is the user's own name, so the path is the one witness both a + // gateway and a relay share. A path that names no Go plane is Zen, whatever + // the turn's provider says. + await seedDivergentCatalog(); + const als = new AsyncLocalStorage(); + const config = resolveConfig({ freeModelMarker: "none-of-these" }); + const send = async (provider: string, url: string): Promise => { + const { capture, mockFetch } = createCaptureFetch(); + const value = openCodeSessionIdFor(`route-${provider}-${url}`); + await als.run({ provider, value }, async () => { + await patchFetch( + mockFetch, + als, + config + )(url, { + body: JSON.stringify({ input: "hi", model: GO_FREE_PROBE }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + return toolNamesOf(parseJsonBody(capture.init?.body)); + }; + + // A relay's Go-shaped path is the Go plane, even on a Zen turn. + expect( + await send("opencode", "https://relay.example/zen/go/v1/responses") + ).toEqual(["read", "bash"]); + // A path naming no Go plane is Zen, even on a Go turn. + expect( + await send("opencode-go", "https://relay.example/v1/responses") + ).toBeUndefined(); + // On the gateway: the URL's plane decides, whatever the turn says. + expect( + await send("opencode", "https://opencode.ai/zen/go/v1/responses") + ).toEqual(["read", "bash"]); + expect( + await send("opencode-go", "https://opencode.ai/zen/v1/responses") + ).toBeUndefined(); + }); + it("skips tool injection for paid models, other paths, and invalid JSON", async () => { const als = new AsyncLocalStorage(); const testSession = openCodeSessionIdFor("skip-cases"); diff --git a/test/free-tier-contract.test.ts b/test/free-tier-contract.test.ts index 804a8176..6b97897d 100644 --- a/test/free-tier-contract.test.ts +++ b/test/free-tier-contract.test.ts @@ -13,9 +13,11 @@ import { describe, expect, it } from "vitest"; -import { OPENCODE_ZEN_CATALOG } from "../src/catalog-data.ts"; -import { findModelSpec } from "../src/models-catalog.ts"; -import { maybeInjectCoreTools } from "../src/tool-fallback.ts"; +import { + findModelSpecOn, + maybeInjectCoreTools, + OPENCODE_ZEN_CATALOG, +} from "./support/src.ts"; const ROUTES = [ "https://opencode.ai/zen/v1/chat/completions", @@ -29,14 +31,17 @@ const freeModelIds = (): string[] => { ids.add(spec.id); } } - // findModelSpec covers ids the live map keys differently (prefixes). + // The live map keys some ids differently (prefixes). The lookup reads the + // Zen plane because every route below posts to a Zen URL, and the injector + // reads its plane from that URL (the plane-blind row is its fallback), so + // the set and the injector agree on what is free. for (const id of [ "mimo-v2.6-flash-free", "big-pickle", "beam-free", "muse-spark-1.2-contributor-free", ]) { - if (findModelSpec(id)?.is_free === true) { + if (findModelSpecOn("zen", id)?.is_free === true) { ids.add(id); } } @@ -123,6 +128,6 @@ describe("free-tier contract", () => { } } it("big-pickle is free by catalog flag, not by marker", () => { - expect(findModelSpec("big-pickle")?.is_free).toBe(true); + expect(findModelSpecOn("zen", "big-pickle")?.is_free).toBe(true); }); }); diff --git a/test/freshness-scope.test.ts b/test/freshness-scope.test.ts new file mode 100644 index 00000000..806120da --- /dev/null +++ b/test/freshness-scope.test.ts @@ -0,0 +1,941 @@ +/** + * Which freshness checks a pull request is gated on (`scripts/freshness-scope.ts`), + * and the CI wiring that acts on the answer. + * + * Three layers, each performed rather than asserted as a constant: the decision + * for each check on the three kinds of diff that matter (a generator change, a + * refresh, an unrelated change); the import walk over the REAL generator + * sources, read from disk, with a case whose answer is already known; and the + * workflow, parsed as data, with every step condition EVALUATED for each event + * and each scope rather than matched as text. + * + * @module test/freshness-scope.test + */ +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +import { + FRESHNESS_CHECKS, + type FreshnessCheck, + GENERATED_OUTPUTS, + handEditedOutputs, + handEditWarning, + importSpecifiers, + inputsOf, + isFreshnessCheck, + parseChangedFiles, + resolveLocalImport, + scopeFor, + type SourceReader, +} from "../scripts/freshness-scope.ts"; +import { GENERATED_TABLES } from "../scripts/vendor-version.ts"; + +const ROOT = join(import.meta.dirname, ".."); + +/** Every path the disk reader was asked for, so a case can prove it read. */ +const diskReader = (): { read: SourceReader; asked: string[] } => { + const asked: string[] = []; + const read: SourceReader = (path: string): string | undefined => { + asked.push(path); + try { + return readFileSync(join(ROOT, path), "utf8"); + } catch { + return undefined; + } + }; + return { asked, read }; +}; + +/** A reader over an in-memory tree, so a case can move a generator's imports. */ +const treeReader = + (tree: Readonly>): SourceReader => + (path) => + tree[path]; + +const CHECKS = Object.keys(FRESHNESS_CHECKS).filter(isFreshnessCheck); + +/** + * The files commit 2ebaffc changed: a refresh of both generated files, plus the + * tests and docs that follow the vendor's rename. It is the case that turned + * every open pull request red, so it is the case that must now report. + */ +const REFRESH_ONLY = [ + "README.md", + "README.zh-CN.md", + "docs/redirect-planes.md", + "src/catalog-data.ts", + "src/go-limits-data.ts", + "test/attachment-services.test.ts", + "test/catalog.test.ts", + "test/go-google-prepare.test.ts", + "test/go-google.test.ts", + "test/models-discovery.test.ts", + "test/responses-routes.test.ts", + "test/usage-panel.test.tsx", +]; + +const UNRELATED = ["src/usage-pill.tsx", "test/usage-pill-mount.test.tsx"]; + +describe("freshness scope · the walk reads the real generators", () => { + it("finds the catalog parser behind the catalog generator, and proves it read the entry", () => { + const { asked, read } = diskReader(); + const inputs = inputsOf("catalog:shim", read); + // Known answers: the generator itself, the parser it calls, and what the + // parser imports in turn - one level down is what makes it transitive. + expect(inputs).toContain("scripts/regenerate-catalog-shim.ts"); + expect(inputs).toContain("src/models-catalog.ts"); + expect(inputs).toContain("src/utils/guards.ts"); + // `src/config.ts` is loaded (`DEFAULT_GATEWAY_URLS`), so a settings change + // gates this check; `src/session-cost.ts` is reached only through + // `import type`, which loads nothing, so it does not. + expect(inputs).toContain("src/config.ts"); + expect(importSpecifiers(read("src/models-catalog.ts") ?? "")).not.toContain( + "./session-cost.ts" + ); + expect(read("src/models-catalog.ts")).toMatch( + /^import type \{[^}]*\} from "\.\/session-cost\.ts";$/mu + ); + expect(inputs).not.toContain("src/session-cost.ts"); + expect(asked).toContain("scripts/regenerate-catalog-shim.ts"); + expect(asked).toContain("src/models-catalog.ts"); + }); + + it("finds the limits parser behind the limits generator", () => { + const { read } = diskReader(); + const inputs = inputsOf("limits:shim", read); + expect(inputs).toContain("scripts/generate-go-limits.ts"); + expect(inputs).toContain("src/go-limits.ts"); + // The generator's source holds `import type … from "./go-limits.ts"`, but + // as template text: the line it writes into the file it emits, not an + // import of the generator. The premise is checked first, so the second + // assertion has something to miss. (The line is also type-only; the + // known-tree case below separates template text from that on its own.) + const source = read("scripts/generate-go-limits.ts") ?? ""; + expect(source).toMatch( + /^import type \{[^}]*\} from "\.\/go-limits\.ts";$/mu + ); + expect(importSpecifiers(source)).not.toContain("./go-limits.ts"); + }); + + it("counts the pinned version's file as a CLI-version input, though nothing imports it", () => { + const { read } = diskReader(); + // The rules moved into a pure half the script imports, so that module is + // an input too: a change to the rules gates the check that runs them. + expect(inputsOf("cli:version", read)).toStrictEqual([ + "scripts/check-vendor-version.ts", + "scripts/vendor-version.ts", + "src/identity.ts", + ]); + }); + + it("never counts a generated file as an input, though both generators import one", () => { + const { read } = diskReader(); + // The premise, checked on disk: each generator really does import a + // generated file, so the exclusion below is doing work. + expect( + importSpecifiers(read("scripts/regenerate-catalog-shim.ts") ?? "") + ).toContain("../src/catalog-data.ts"); + expect( + importSpecifiers(read("scripts/generate-go-limits.ts") ?? "") + ).toContain("../src/catalog-data.ts"); + for (const check of CHECKS) { + const inputs = inputsOf(check, read); + for (const output of GENERATED_OUTPUTS) { + expect(inputs, `${check} counts ${output}`).not.toContain(output); + } + } + }); + + it("moves the inputs when a generator gains an import", () => { + const { read } = diskReader(); + const entry = "scripts/generate-go-limits.ts"; + const before = inputsOf("limits:shim", read); + expect(before).not.toContain("src/utils/guards.ts"); + const grown: SourceReader = (path) => + path === entry + ? `import { isRecord } from "../src/utils/guards.ts";\n${read(path) ?? ""}` + : read(path); + expect(inputsOf("limits:shim", grown)).toStrictEqual( + [...before, "src/utils/guards.ts"].toSorted() + ); + }); + + it("agrees, on every real input, with an independent line-anchored reading of what Node keeps", () => { + // The walk reads edges with a lexer over Node's own stripped source. The + // oracle here is a different technique over the same stripped source: a + // module specifier on a line that STARTS an import or export statement (or + // closes a multi-line one). Where the two disagree on a local file, one of + // them is wrong - in either direction: an edge the walk dropped turns a + // gate into a report, and an edge it invented gates for nothing. + const { asked, read } = diskReader(); + const inputs = [ + ...new Set(CHECKS.flatMap((check) => inputsOf(check, read))), + ]; + expect(inputs.length).toBeGreaterThan(5); + expect(asked).toContain("src/models-catalog.ts"); + const entries = new Set( + CHECKS.flatMap((check) => FRESHNESS_CHECKS[check].dataInputs) + ); + const local = (importer: string, specifiers: string[]): string[] => + [ + ...new Set( + specifiers.flatMap((specifier) => { + const target = resolveLocalImport(importer, specifier, read); + return target === undefined ? [] : [target]; + }) + ), + ].toSorted(); + for (const file of inputs.filter((input) => !entries.has(input))) { + const source = read(file) ?? ""; + const lineAnchored = [ + ...stripTypeScriptTypes(source).matchAll( + /^\s*(?:(?:import|export)\b|\})[^\n]*?\bfrom\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']/gmu + ), + ].map((match) => match[1] ?? match[2] ?? ""); + expect( + local(file, importSpecifiers(source)), + `${file}: the walk's local edges against the line-anchored reading` + ).toStrictEqual(local(file, lineAnchored)); + } + }); + + it("refuses a walk that could not read its entry", () => { + expect(() => inputsOf("catalog:shim", treeReader({}))).toThrow( + /cannot read scripts\/regenerate-catalog-shim\.ts/u + ); + }); +}); + +describe("freshness scope · the import walk on a known tree", () => { + const tree = { + "scripts/regenerate-catalog-shim.ts": [ + '// import { notReal } from "../src/commented.ts";', + '/* import "../src/also-commented.ts"; */', + 'import { readFileSync } from "node:fs";', + 'import { a } from "../src/a.ts";', + 'import type { B } from "../src/b";', + 'export type { T } from "../src/types-only.ts";', + 'import { type K } from "../src/kept.ts";', + 'import "../src/side-effect.ts";', + 'const lazy = await import("./lazy.ts");', + 'import { OPENCODE_GO_CATALOG } from "../src/catalog-data.ts";', + // A string and a regex that each hold `/*`: a comment-stripping pattern + // took them for a comment's start and swallowed every edge up to the + // next `*/`. A lexer reads them as what they are. + 'const GLOB = "catalog/*";', + 'export { e } from "../src/after-glob.ts";', + String.raw`const trim = (url: string): string => url.replace(/\/*$/u, "");`, + 'export { f } from "../src/after-regex.ts";', + "/** the doc block whose close ended the fake comment */", + // An import written into a template is text the script emits, not an + // edge of the script itself. + 'const emitted = `\nimport { g } from "../src/in-template.ts";\n`;', + ].join("\n"), + "scripts/lazy.ts": "", + "src/a.ts": 'export { c } from "./nested/index.ts";\nimport z from "pkg";', + "src/b.ts": 'import { a } from "./a.ts";', + "src/catalog-data.ts": 'import { hidden } from "./only-via-output.ts";', + "src/nested/index.ts": 'import { d } from "../d.tsx";', + "src/d.tsx": "", + "src/kept.ts": "", + "src/types-only.ts": "", + "src/only-via-output.ts": "", + "src/side-effect.ts": "", + "src/after-glob.ts": "", + "src/after-regex.ts": "", + "src/in-template.ts": "", + }; + + it("follows every loading import form, transitively, from each importer's own directory", () => { + // `src/b.ts` and `src/types-only.ts` are reached only by a whole-statement + // `import type` / `export type`, which loads nothing; `src/kept.ts` is + // reached by `import { type K }`, which strips to `import {}` and still + // loads the module. `src/after-glob.ts` and `src/after-regex.ts` follow a + // `/*` inside a string and a regex; `src/in-template.ts` is named only + // inside a template, so it is no input. `scripts/lazy.ts` is reached by a + // dynamic `import()`, which loads it as surely as a static one. + expect(inputsOf("catalog:shim", treeReader(tree))).toStrictEqual([ + "scripts/lazy.ts", + "scripts/regenerate-catalog-shim.ts", + "src/a.ts", + "src/after-glob.ts", + "src/after-regex.ts", + "src/d.tsx", + "src/kept.ts", + "src/nested/index.ts", + "src/side-effect.ts", + ]); + }); + + it("tells a type-only statement from a type alias and an inline type", () => { + expect( + importSpecifiers( + [ + 'import type { A } from "./a.ts";', + 'import type * as N from "./n.ts";', + 'import type D from "./d.ts";', + 'export type * from "./s.ts";', + // A default import NAMED `type` is a real one. + 'import type from "./t.ts";', + 'import { type K, v } from "./k.ts";', + // An alias without its semicolon must not swallow the next import. + "export type Alias = { a: string }", + 'import { c } from "./c.ts";', + ].join("\n") + ).toSorted() + ).toStrictEqual(["./c.ts", "./k.ts", "./t.ts"]); + }); + + it("neither counts a generated file nor walks into it", () => { + const inputs = inputsOf("catalog:shim", treeReader(tree)); + expect(inputs).not.toContain("src/catalog-data.ts"); + expect(inputs).not.toContain("src/only-via-output.ts"); + }); + + it("counts a JSON a generator imports as a leaf input, and does not lex it", () => { + // Node loads this import under the generators' own flag, so the walk must + // follow it - and must not hand JSON to the TypeScript stripper, which + // would throw. `{ "a": "./not-an-edge.ts" }` would be an edge if lexed. + const jsonTree = { + "package.json": '{ "version": "1.0.0", "a": "./not-an-edge.ts" }', + "not-an-edge.ts": "", + "scripts/check-vendor-version.ts": + 'import pkg from "../package.json" with { type: "json" };', + "src/identity.ts": "", + }; + expect(inputsOf("cli:version", treeReader(jsonTree))).toStrictEqual([ + "package.json", + "scripts/check-vendor-version.ts", + "src/identity.ts", + ]); + }); + + it("names the file when a reached module cannot be stripped", () => { + // An `enum` is what Node's strip mode refuses; the walk fails closed, and + // the message says where to look. + const enumTree = { + "scripts/regenerate-catalog-shim.ts": 'import { E } from "../src/e.ts";', + "src/e.ts": "export enum E { A }", + }; + expect(() => inputsOf("catalog:shim", treeReader(enumTree))).toThrow( + /^src\/e\.ts: /u + ); + }); + + it("resolves only local specifiers that name a file", () => { + const read = treeReader(tree); + expect(resolveLocalImport("src/a.ts", "pkg", read)).toBeUndefined(); + expect(resolveLocalImport("src/a.ts", "node:fs", read)).toBeUndefined(); + expect( + resolveLocalImport("src/a.ts", "./missing.ts", read) + ).toBeUndefined(); + expect(resolveLocalImport("src/a.ts", "./nested", read)).toBe( + "src/nested/index.ts" + ); + expect(resolveLocalImport("src/nested/index.ts", "../d", read)).toBe( + "src/d.tsx" + ); + }); +}); + +describe("freshness scope · the decision", () => { + const { read } = diskReader(); + const inputs = Object.fromEntries( + CHECKS.map((check) => [check, inputsOf(check, read)]) + ) as Record; + + const GENERATOR_CHANGE: Record = { + "catalog:shim": ["src/models-catalog.ts", "src/catalog-data.ts"], + "cli:version": ["src/identity.ts", "README.md"], + "limits:shim": ["scripts/generate-go-limits.ts", "src/go-limits-data.ts"], + }; + + for (const check of CHECKS) { + it(`${check}: a pull request touching its inputs gates`, () => { + expect(scopeFor(GENERATOR_CHANGE[check], inputs[check])).toBe("gate"); + // The entry alone is enough. + expect(scopeFor([FRESHNESS_CHECKS[check].entry], inputs[check])).toBe( + "gate" + ); + }); + + it(`${check}: a pull request carrying only a refresh reports`, () => { + expect(scopeFor(REFRESH_ONLY, inputs[check])).toBe("report"); + }); + + it(`${check}: an unrelated pull request reports, and so does an empty diff`, () => { + expect(scopeFor(UNRELATED, inputs[check])).toBe("report"); + expect(scopeFor([], inputs[check])).toBe("report"); + }); + } + + it("cli:version: a docs-only diff reports - its copies are gated by the separate offline step", () => { + // The registry half of cli:version follows the vendor; a stale COPY in a + // doc is the repository's own, and `cli:version -- --copies` gates every + // pull request on it (the wiring and the script cases below). + expect(scopeFor(["docs/deep-dive.md"], inputs["cli:version"])).toBe( + "report" + ); + }); + + it("gates one check without gating the others", () => { + const changed = ["src/go-limits.ts"]; + expect(scopeFor(changed, inputs["limits:shim"])).toBe("gate"); + expect(scopeFor(changed, inputs["catalog:shim"])).toBe("report"); + expect(scopeFor(changed, inputs["cli:version"])).toBe("report"); + }); + + it("names a generated file changed without its generator, and only then", () => { + expect( + handEditedOutputs("catalog:shim", REFRESH_ONLY, inputs["catalog:shim"]) + ).toStrictEqual(["src/catalog-data.ts"]); + expect( + handEditedOutputs("limits:shim", REFRESH_ONLY, inputs["limits:shim"]) + ).toStrictEqual(["src/go-limits-data.ts"]); + // A generator change is expected to carry its output: no warning. + expect( + handEditedOutputs( + "catalog:shim", + GENERATOR_CHANGE["catalog:shim"], + inputs["catalog:shim"] + ) + ).toStrictEqual([]); + expect( + handEditedOutputs("cli:version", REFRESH_ONLY, inputs["cli:version"]) + ).toStrictEqual([]); + expect( + handEditedOutputs("catalog:shim", UNRELATED, inputs["catalog:shim"]) + ).toStrictEqual([]); + }); + + it("writes the warning as an annotation on the file it names", () => { + expect(handEditWarning("catalog:shim", "src/catalog-data.ts")).toMatch( + /^::warning file=src\/catalog-data\.ts::src\/catalog-data\.ts changed.*pnpm run catalog:shim -- --write/u + ); + }); + + it("reads git's changed-file list, one path per line", () => { + expect(parseChangedFiles("a.ts\r\nb.ts\n\n c.ts \n")).toStrictEqual([ + "a.ts", + "b.ts", + "c.ts", + ]); + }); +}); + +/** A GitHub Actions `if:` expression, evaluated over the few names the job uses. */ +const evaluate = ( + expression: string, + names: Readonly> +): boolean | string => { + const tokens = + expression.match(/'[^']*'|==|!=|&&|\|\||[()!]|[A-Za-z_][\w.-]*/gu) ?? []; + const consumed = tokens.join(""); + if (consumed !== expression.replaceAll(/\s+/gu, "")) { + throw new Error(`unparsed text in: ${expression}`); + } + let at = 0; + const truthy = (value: boolean | string): boolean => + typeof value === "boolean" ? value : value !== ""; + const primary = (): boolean | string => { + const token = tokens[at]; + at += 1; + if (token === "(") { + const value = or(); + const closing = tokens[at]; + at += 1; + if (closing !== ")") throw new Error(`unbalanced: ${expression}`); + return value; + } + if (token === "!") return !truthy(primary()); + if (token?.startsWith("'")) return token.slice(1, -1); + if (token !== undefined && Object.hasOwn(names, token)) { + return names[token] ?? ""; + } + throw new Error(`unknown name ${token} in: ${expression}`); + }; + const comparison = (): boolean | string => { + const left = primary(); + const op = tokens[at]; + if (op === "==" || op === "!=") { + at += 1; + const right = primary(); + return op === "==" ? left === right : left !== right; + } + return left; + }; + const and = (): boolean | string => { + let value = comparison(); + while (tokens[at] === "&&") { + at += 1; + const right = comparison(); + value = truthy(value) && truthy(right); + } + return value; + }; + const or = (): boolean | string => { + let value = and(); + while (tokens[at] === "||") { + at += 1; + const right = and(); + value = truthy(value) || truthy(right); + } + return value; + }; + const result = or(); + if (at !== tokens.length) throw new Error(`trailing tokens: ${expression}`); + return truthy(result); +}; + +describe("freshness scope · the CI wiring, parsed", () => { + interface Step { + readonly id?: string; + readonly if?: string; + readonly name?: string; + readonly run?: string; + readonly uses?: string; + readonly with?: Record; + } + const workflow = parse( + readFileSync(join(ROOT, ".github", "workflows", "ci.yml"), "utf8") + ) as { jobs: Record }; + const steps = workflow.jobs.catalog?.steps ?? []; + + const scopeStep = steps.find((step) => step.id === "scope"); + /** `decide ` lines in the scope step: key -> check. */ + const keys = Object.fromEntries( + [...(scopeStep?.run ?? "").matchAll(/^\s*decide (\w+) (\S+)\s*$/gmu)].map( + (match) => [match[2], match[1]] + ) + ) as Record; + + /** + * Every step that runs one check in full: minus the nightly `--write` + * refresh, and minus the CLI version's offline `--copies` gate, which is + * pinned on its own below. + */ + const runsOf = (check: FreshnessCheck): Step[] => + steps.filter( + (step) => + step.run !== undefined && + new RegExp(`pnpm run ${check}(?! -- --(?:write|copies))\\b`, "u").test( + step.run + ) + ); + + const runs = ( + step: Step, + event: string, + outputs: Readonly> + ): boolean => + step.if === undefined + ? true + : evaluate(step.if, { + "github.event_name": event, + ...Object.fromEntries( + ["catalog", "limits", "cli"].map((key) => [ + `steps.scope.outputs.${key}`, + outputs[key] ?? "", + ]) + ), + }) === true; + + it("read the job: the checkout reaches the base, and the scope step decides each check", () => { + expect(steps.length).toBeGreaterThan(5); + const checkout = steps.find((step) => + step.uses?.startsWith("actions/checkout@") + ); + expect(checkout?.with?.["fetch-depth"]).toBe(2); + expect(scopeStep?.if).toBe("github.event_name == 'pull_request'"); + expect(scopeStep?.run).toContain("git diff --name-only HEAD^1 HEAD"); + expect(scopeStep?.run).toContain("scripts/freshness-scope.ts"); + expect(Object.keys(keys).toSorted()).toStrictEqual([...CHECKS].toSorted()); + }); + + /** + * One step's `run` text, executed the way the runner executes it (`bash -e`, + * GitHub's default shell, which has no pipefail), in the repository, with a + * shell function named in `prelude` standing in for a command the step calls. + */ + const execute = ( + run: string, + prelude: string, + env: Readonly> = {} + ): { + status: number | null; + stdout: string; + stderr: string; + output: string; + calls: string; + } => { + const temp = mkdtempSync(join(tmpdir(), "ci-step-")); + try { + const result = spawnSync("bash", ["-e", "-c", `${prelude}\n${run}`], { + cwd: ROOT, + encoding: "utf8", + env: { + ...process.env, + ...env, + CALLS: join(temp, "calls"), + GITHUB_OUTPUT: join(temp, "github-output"), + PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: temp, + }, + }); + const readOr = (path: string): string => { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } + }; + return { + calls: readOr(join(temp, "calls")), + output: readOr(join(temp, "github-output")), + status: result.status, + stderr: result.stderr, + stdout: result.stdout, + }; + } finally { + rmSync(temp, { force: true, recursive: true }); + } + }; + + /** `pnpm` as a function that records its arguments and fails, naming a cause. */ + const FAILING_PNPM = + 'pnpm() { echo "$*" >> "$CALLS"; echo "error: boom" >&2; return 1; }'; + const PASSING_PNPM = 'pnpm() { echo "$*" >> "$CALLS"; return 0; }'; + + /** Gate or report, by what the step DOES when the check fails. */ + const classify = (step: Step): "gate" | "report" => { + const failing = execute(step.run ?? "", FAILING_PNPM); + if (failing.status !== 0) { + return "gate"; + } + expect(failing.stdout, step.name).toMatch(/^::warning::/mu); + return "report"; + }; + + it("runs the scope step's own shell and the CLI it calls: a generator change gates, a refresh reports", () => { + expect(scopeStep?.run).toBeDefined(); + // `git` as a function: it records what the step asked for and answers the + // diff from a file, so the real CLI reads a known change list. + const scopeOf = (changed: readonly string[]) => { + const diff = join( + mkdtempSync(join(tmpdir(), "ci-diff-")), + "changed-files" + ); + writeFileSync(diff, changed.map((file) => `${file}\n`).join("")); + try { + return execute( + scopeStep?.run ?? "", + 'git() { echo "$*" >> "$CALLS"; cat "$FIXTURE_DIFF"; }', + { FIXTURE_DIFF: diff } + ); + } finally { + rmSync(dirname(diff), { force: true, recursive: true }); + } + }; + + const generator = scopeOf(["scripts/regenerate-catalog-shim.ts"]); + expect(generator.stderr).toBe(""); + expect(generator.status).toBe(0); + expect(generator.calls).toBe("diff --name-only HEAD^1 HEAD\n"); + expect(generator.output).toBe("catalog=gate\nlimits=report\ncli=report\n"); + // The output keys are the ones the `decide` lines name, in their order - + // and those are the keys the `if:` conditions read (the cases below). + expect( + [...generator.output.matchAll(/^(\w+)=/gmu)].map((match) => match[1]) + ).toStrictEqual(Object.values(keys)); + + const refresh = scopeOf(["src/catalog-data.ts", "src/go-limits-data.ts"]); + expect(refresh.status).toBe(0); + expect(refresh.output).toBe("catalog=report\nlimits=report\ncli=report\n"); + expect(refresh.stdout).toContain( + "::warning file=src/catalog-data.ts::src/catalog-data.ts changed" + ); + expect(refresh.stdout).toContain( + "::warning file=src/go-limits-data.ts::src/go-limits-data.ts changed" + ); + // The CLI's warnings are its own: Node's experimental notice is silenced. + expect(refresh.stdout).not.toContain("ExperimentalWarning"); + + const pin = scopeOf(["src/identity.ts"]); + expect(pin.output).toBe("catalog=report\nlimits=report\ncli=gate\n"); + }); + + it("the CLI refuses an unknown check with a usage line and exit 2", () => { + const run = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(ROOT, "scripts", "freshness-scope.ts"), + "catalog", + ], + { cwd: ROOT, encoding: "utf8", input: "src/catalog-data.ts\n" } + ); + expect(run.status).toBe(2); + expect(run.stdout).toBe(""); + expect(run.stderr).toMatch(/^usage: freshness-scope\.ts { + // Node realpaths the main module's URL but only resolves `argv[1]`; a + // run-as-script guard comparing the two printed nothing and exited 0. + const temp = mkdtempSync(join(tmpdir(), "freshness-link-")); + try { + const link = join(temp, "scripts"); + symlinkSync(join(ROOT, "scripts"), link, "dir"); + const run = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(link, "freshness-scope.ts"), + "catalog:shim", + ], + { + cwd: ROOT, + encoding: "utf8", + input: "scripts/regenerate-catalog-shim.ts\n", + } + ); + expect(run.stderr).toBe(""); + expect(run.stdout).toBe("gate\n"); + expect(run.status).toBe(0); + } finally { + rmSync(temp, { force: true, recursive: true }); + } + }); + + it("the scope step runs before every step that reads its outputs", () => { + const at = steps.findIndex((step) => step.id === "scope"); + expect(at).toBeGreaterThan(-1); + const readers = steps + .map((step, index) => ({ index, step })) + .filter(({ step }) => step.if?.includes("steps.scope.outputs.") === true); + expect(readers.length).toBeGreaterThanOrEqual(6); + for (const { index, step } of readers) { + expect(index, step.name).toBeGreaterThan(at); + } + }); + + for (const check of CHECKS) { + it(`${check}: a pull request runs exactly one of its gate and its report, gating unless the answer is "report"`, () => { + const key = keys[check] ?? ""; + const candidates = runsOf(check); + expect(candidates).toHaveLength(2); + // Performed: each step run with a failing check, and with a passing one. + const gates = candidates.filter((step) => classify(step) === "gate"); + const reports = candidates.filter((step) => classify(step) === "report"); + expect(gates, `${check} gate steps`).toHaveLength(1); + expect(reports, `${check} report steps`).toHaveLength(1); + const [gate] = gates; + const [report] = reports; + for (const step of [gate, report]) { + const passing = execute(step?.run ?? "", PASSING_PNPM); + expect(passing.status, step?.name).toBe(0); + expect(passing.stdout, step?.name).not.toMatch( + /::(?:warning|error)::/u + ); + expect(passing.calls.split("\n")[0], step?.name).toBe(`run ${check}`); + } + + // Fail closed: '' is a lost answer (a renamed output, a moved step). + const other = { catalog: "report", cli: "report", limits: "report" }; + for (const scope of ["", "gate", "report"]) { + const outputs = { ...other, [key]: scope }; + expect(runs(gate!, "pull_request", outputs), `gate on '${scope}'`).toBe( + scope !== "report" + ); + expect( + runs(report!, "pull_request", outputs), + `report on '${scope}'` + ).toBe(scope === "report"); + } + // The scope step does not run on a push: its outputs are empty there. + expect(runs(gate!, "push", {})).toBe(false); + expect(runs(report!, "push", {})).toBe(true); + // The nightly is unchanged: never gated; it reports the CLI version and + // regenerates the shims in its own step. + expect(runs(gate!, "schedule", {})).toBe(false); + expect(runs(report!, "schedule", {})).toBe(check === "cli:version"); + // A manual dispatch, as the job's comment says: no gate, and only the + // CLI version reports. + expect(runs(gate!, "workflow_dispatch", {})).toBe(false); + expect(runs(report!, "workflow_dispatch", {})).toBe( + check === "cli:version" + ); + }); + } + + it("cli:version's offline copy scan gates every pull request, whatever the scope", () => { + const copies = steps.filter((step) => + /pnpm run cli:version -- --copies\b/u.test(step.run ?? "") + ); + expect(copies).toHaveLength(1); + const [step] = copies; + // A gate: its failure fails the step. + expect(classify(step!)).toBe("gate"); + for (const scope of ["", "gate", "report"]) { + expect(runs(step!, "pull_request", { cli: scope }), scope).toBe(true); + } + // Elsewhere the full check's report step runs the scan too. + expect(runs(step!, "push", {})).toBe(false); + }); + + it("the CLI report step quotes the script's own cause, and needs its pipefail to", () => { + const report = runsOf("cli:version").find( + (step) => classify(step) === "report" + ); + const run = report?.run ?? ""; + const warned = execute(run, FAILING_PNPM); + expect(warned.status).toBe(0); + expect(warned.stdout).toMatch(/^::warning::error: boom$/mu); + // The control: the same step without its pipefail never warns, because + // `tee` answers for the pipeline. + const withoutPipefail = run.replace(/^\s*set -o pipefail\s*$/mu, ""); + expect(withoutPipefail).not.toBe(run); + const silent = execute(withoutPipefail, FAILING_PNPM); + expect(silent.status).toBe(0); + expect(silent.stdout).not.toContain("::warning::"); + }); + + it("each check's entry is the script package.json runs for it", () => { + const { scripts } = JSON.parse( + readFileSync(join(ROOT, "package.json"), "utf8") + ) as { scripts: Record }; + for (const check of CHECKS) { + expect(scripts[check]?.split(/\s+/u), check).toContain( + FRESHNESS_CHECKS[check].entry + ); + } + }); + + it("the evaluator answers the cases it is trusted with", () => { + expect( + evaluate("a == 'x' && (b == 'y' || !c)", { a: "x", b: "z", c: "" }) + ).toBe(true); + expect(evaluate("a != 'x'", { a: "x" })).toBe(false); + expect(() => evaluate("steps.scop.outputs.catalog == 'gate'", {})).toThrow( + /unknown name/u + ); + }); +}); + +describe("cli:version · the offline copy scan (`--copies`)", () => { + const SCRIPT = join(ROOT, "scripts", "check-vendor-version.ts"); + /** + * Where the pin lives, as freshness-scope counts it: the fixture writes it + * THERE, so a passing run proves the script reads the very file the scope + * treats as this check's data input, and moving one without the other fails. + */ + const PIN_FILE = FRESHNESS_CHECKS["cli:version"].dataInputs[0] ?? ""; + /** + * A version only the vendor's data says. Built from parts: the real copy + * scan reads this test file, so a spelled-out CLI literal here would fail + * the very gate it is testing. + */ + const VENDOR_ONLY = [9, 9, 9].join("."); + + /** + * A throwaway tree with a pin, one doc copy, and each generated table + * carrying a version that disagrees with the pin - vendor data, which the + * scan must skip - and the script run in it. + */ + const runIn = ( + pin: string, + copy: string, + args: readonly string[] + ): { status: number | null; stdout: string; stderr: string } => { + const root = mkdtempSync(join(tmpdir(), "cli-version-")); + try { + for (const [path, text] of Object.entries({ + "README.md": "# fixture\n", + "docs/x.md": `User-Agent: opencode/${copy}\n`, + [PIN_FILE]: `export const OPENCODE_CLI_VERSION = "${pin}";\n`, + ...Object.fromEntries( + GENERATED_OUTPUTS.map((output) => [ + output, + // Assembled, never spelled: this file is in the real scan's path. + `export const ROW = { ua: "${["opencode", VENDOR_ONLY].join("/")}", seen: "v${pin.split(".").slice(0, 2).join(".")}.99" };\n`, + ]) + ), + })) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); + } + const run = spawnSync( + process.execPath, + ["--experimental-strip-types", SCRIPT, ...args], + { cwd: root, encoding: "utf8" } + ); + return { status: run.status, stderr: run.stderr, stdout: run.stdout }; + } finally { + rmSync(root, { force: true, recursive: true }); + } + }; + + it("skips exactly the files the scope calls generated", () => { + // Two lists, kept apart on purpose (an import would make the scope module an + // input of this check); this is what keeps them the same list. + expect([...GENERATED_TABLES].toSorted()).toStrictEqual( + [...GENERATED_OUTPUTS].toSorted() + ); + }); + + it("passes on this repository itself, as CI's pull-request gate will run it", () => { + // Offline, and the same scan CI gates every pull request on: a stale copy + // anywhere in the tree - this file included - fails here first. + const run = spawnSync( + process.execPath, + ["--experimental-strip-types", SCRIPT, "--copies"], + { cwd: ROOT, encoding: "utf8" } + ); + expect(run.stderr).toBe(""); + expect(run.status).toBe(0); + // A known answer: the tree carries copies, so the scan read something. + expect(run.stdout).toMatch(/^ok {3}[1-9]\d* copy\/copies /u); + }); + + it("fails on a copy that disagrees with the pin, naming the cause on its first line", () => { + const run = runIn("0.0.1", "0.0.0", ["--copies"]); + expect(run.status).toBe(1); + expect(run.stderr.split("\n")[0]).toBe( + `error: these copies do not say 0.0.1, which is what ${PIN_FILE} declares:` + ); + expect(run.stderr).toContain("0.0.0 in docs/x.md"); + // The generated tables' disagreeing versions are not reported as copies. + for (const output of GENERATED_OUTPUTS) { + expect(run.stderr).not.toContain(output); + } + }); + + it("passes when the copies agree, without asking the registry, and skips the generated tables", () => { + // 0.0.1 was never published, so a registry comparison would fail: the + // pass proves the scan stopped before it. Both generated tables say a + // disagreeing CLI literal and a bare `v0.0.99`: the pass proves the scan + // skipped them, because a vendor move cannot be a stale copy. + expect(PIN_FILE).not.toBe(""); + const run = runIn("0.0.1", "0.0.1", ["--copies"]); + expect(run.stderr).toBe(""); + expect(run.status).toBe(0); + expect(run.stdout).toMatch( + /^ok {3}1 copy\/copies .*say 0\.0\.1.*registry not asked/u + ); + }); +}); diff --git a/test/go-discovery.test.ts b/test/go-discovery.test.ts index 92f6a811..c057b8b0 100644 --- a/test/go-discovery.test.ts +++ b/test/go-discovery.test.ts @@ -52,11 +52,11 @@ import { recordCapturedApiKey, } from "../src/key-capture.ts"; import { - createMockContext, createOpencodeStore, type FixtureCredential, type FixtureStore, -} from "./test-helpers.ts"; +} from "./support/opencode-store-fixture.ts"; +import { createMockContext } from "./test-helpers.ts"; /** The Zen plane, which discovery rewrites onto the Go plane. */ const ZEN_BASE = "https://opencode.ai/zen/v1"; @@ -947,21 +947,17 @@ describe("resolveRoutedKey", () => { expect(routed.provider).toBe("opencode-go"); }); - it("classifies a renamed route by the key prefix alone", async () => { - // The prefix is intrinsic to the credential, so it survives a route rename; - // a route id says nothing about the account behind it. - recordCapturedApiKey("sk-rotated-key", "my-relay", "https://relay.test/v1"); - const byGoPrefix = await resolveRoutedKey(composition(), "my-relay"); - expect(byGoPrefix.tier).toBe("go"); - clearCapturedApiKeys(); - recordCapturedApiKey("oc_sk_zen-key", "my-relay", "https://relay.test/v1"); - const byZenPrefix = await resolveRoutedKey(composition(), "my-relay"); - expect(byZenPrefix.tier).toBe("zen"); - clearCapturedApiKeys(); - recordCapturedApiKey("opaque-token", "my-relay", "https://relay.test/v1"); - const unknown = await resolveRoutedKey(composition(), "my-relay"); - expect(unknown.tier).toBe("unknown"); - expect(unknown.key).toBe("opaque-token"); + it("does not classify a renamed route by the key's prefix", async () => { + // The route decides, and a renamed route names no plane. The prefix used to + // decide instead, and it is not OpenCode's: `oc_sk_` is issued for Go keys + // too, and `sk-` is every OpenAI key. + for (const key of ["sk-rotated-key", "oc_sk_zen-key", "opaque-token"]) { + clearCapturedApiKeys(); + recordCapturedApiKey(key, "my-relay", "https://relay.test/v1"); + const routed = await resolveRoutedKey(composition(), "my-relay"); + expect(routed.key).toBe(key); + expect(routed.tier).toBe("unknown"); + } }); it("accepts a Zen key for a route that legitimately owns one", async () => { @@ -976,13 +972,14 @@ describe("resolveRoutedKey", () => { ).toBeUndefined(); }); - it("takes the most recent captured key when the route has none of its own", async () => { + it("reports the tier of the route it was asked about", async () => { // A non-Go route is not tier-filtered, so a key captured elsewhere can - // answer for it — which is why the reported tier comes from the key. + // answer for it; the tier reported is still the route's, never a guess + // from the key's shape. recordCapturedApiKey("sk-go-key-value", "opencode-go", GO_REQUEST); const routed = await resolveRoutedKey(composition(), "opencode"); expect(routed.key).toBe("sk-go-key-value"); - expect(routed.tier).toBe("go"); + expect(routed.tier).toBe("zen"); }); it("uses the route's own declared reference, defaulting each plane differently", async () => { diff --git a/test/host-tokens.test.ts b/test/host-tokens.test.ts index 1bc7fe2b..c6f82075 100644 --- a/test/host-tokens.test.ts +++ b/test/host-tokens.test.ts @@ -16,6 +16,20 @@ * they never use is either undefined or private to a subsystem the plugin is * not part of. * + * **And every token the settings card names is in that vocabulary**, whether or + * not it carries a fallback: the card and every file it renders that names a + * token are scanned. Which token the mounted card renders for a warning is + * asserted in settings-card-purge and settings-store-inspect. The meter + * (`usage-ui.ts`, `usage-panel.tsx`, `usage-pill.tsx`) is left out because the + * card does not render it. `usage-pill.tsx` would pass the scan; the other two + * would not, for measured reasons rather than broken tokens: each spells the + * `--dsw-alias-state-*` wildcard in a prose comment, which the scan reads as + * the name `--dsw-alias-state-`, and `usage-ui.ts` names `--dsw-radius-full`, + * which the primitives never use and which sits behind a literal `999px` + * fallback — the case the first test allows. Scanning the meter would mean + * re-spelling those two comments and letting a literal fallback through, not + * handling a template: no meter file builds a token name from one. + * * @module test/host-tokens.test */ @@ -59,6 +73,21 @@ const hostVocabulary = (): Set => { return found; }; +/** + * The card files whose tokens are checked against the host's vocabulary. A file + * that names no token (`settings-boolean-field.tsx`) is not listed, since the + * scan treats an empty file as one it failed to read. + */ +const CARD_FILES = [ + "src/settings-card.tsx", + "src/settings-store-inspect.tsx", + "src/settings-usage.tsx", + "src/settings-field-shell.tsx", + "src/settings-choice-field.tsx", +]; + +const TOKEN = /--dsw-[\w-]+/gu; + describe("the host's design tokens", () => { it("never falls back from one token to another", () => { // A token the host does not define resolves to nothing. Where the @@ -101,4 +130,24 @@ describe("the host's design tokens", () => { expect(vocabulary.has("--dsw-alias-state-warn-label")).toBe(true); expect(vocabulary.has("--dsw-alias-label-warning")).toBe(false); }); + + it("every token the settings card names is one the host's primitives use", () => { + const vocabulary = hostVocabulary(); + // A read of a directory that moved finds nothing, and "nothing" must not + // read as "the token is absent". + expect(vocabulary.size).toBeGreaterThan(0); + + for (const file of CARD_FILES) { + const named = new Set( + [ + ...readFileSync(join(process.cwd(), file), "utf8").matchAll(TOKEN), + ].map((match) => match[0]) + ); + // A file that names no token at all is a scan that read nothing. + expect(named.size, file).toBeGreaterThan(0); + for (const token of named) { + expect(vocabulary.has(token), `${file} names ${token}`).toBe(true); + } + } + }); }); diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 0d418fb3..44914fa2 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveConfig } from "../src/config.ts"; import { apply, type CordisContext, @@ -19,6 +20,8 @@ import { clearSessionUsageStore, RESPONSES_ROUTE, } from "../src/index.ts"; +import { mountInternalRoutes } from "../src/lifecycle.ts"; +import { findModelSpecOn } from "../src/models-catalog.ts"; import { collectUnknown, createMockStream, @@ -259,6 +262,73 @@ describe("apply (plugin lifecycle)", () => { expect(warned.join(" ")).toContain("neither ctx.plugin nor ctx.provide"); }); + it("answers discovery from the LATEST activation's settings after a reload", async () => { + // The host refuses a namespace that is still held, the way cordis does. The + // earlier shape re-registered on every apply, swallowed that refusal, and so + // kept answering with the first activation's settings for the process's life. + const discover = new Map Promise>(); + const ctx = { + effect: () => {}, + llm: { + registerModelDiscovery: (ns: string, fn: () => Promise) => { + if (discover.has(ns)) { + throw new Error(`DUPLICATE_DISCOVERY: ${ns}`); + } + discover.set(ns, fn); + return () => discover.delete(ns); + }, + }, + on: () => {}, + } as unknown as CordisContext; + const contextOf = async (id: string): Promise => { + const rows = (await discover.get("opencode")!()) as { + id: string; + contextWindow: number; + }[]; + return rows.find((row) => row.id === id)?.contextWindow; + }; + + apply(ctx, { maxContextWindow: 262_144 }); + expect(await contextOf("claude-sonnet-4-5")).toBe(262_144); + + apply(ctx, { maxContextWindow: 65_536 }); + expect(await contextOf("claude-sonnet-4-5")).toBe(65_536); + + // Turning the feature off on a reload empties the answer rather than + // leaving the old catalog published. + apply(ctx, { enrichModels: false }); + expect(await discover.get("opencode")!()).toEqual([]); + expect([...discover.keys()].toSorted()).toEqual( + ["dsh-opencode-patch", "opencode", "opencode-go"].toSorted() + ); + }); + + it("registers each discovery namespace on its own, and says which one failed", () => { + // One `try` around all three meant a refusal of the first skipped the rest. + const registered: string[] = []; + const warn = vi.fn(); + const ctx = { + effect: () => {}, + llm: { + registerModelDiscovery: (ns: string) => { + if (ns === "dsh-opencode-patch") { + throw new Error("refused"); + } + registered.push(ns); + }, + }, + logger: { info: () => {}, warn }, + on: () => {}, + } as unknown as CordisContext; + + apply(ctx); + expect(registered.toSorted()).toEqual(["opencode", "opencode-go"]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("model discovery for dsh-opencode-patch"), + expect.any(Error) + ); + }); + it("registers no model discovery when enrichModels is off", () => { const registered: string[] = []; const ctx = { @@ -784,6 +854,58 @@ describe("apply (plugin lifecycle)", () => { expect(recorded?.costUsd).toBeGreaterThan(0); }); + it("prices a turn at the rate of the plane its route belongs to", async () => { + // The planes price some shared ids differently. A Go-first lookup billed + // every Zen turn on those at the Go rate, so each plane is checked here + // against its own catalog row rather than against a hardcoded number. + clearSessionUsageStore(); + let streamHandler: + | ((options: unknown, next: () => unknown) => unknown) + | undefined; + const ctx: CordisContext = { + effect: () => {}, + on: ( + _event: string, + handler: (options: unknown, next: () => unknown) => unknown + ) => { + streamHandler = handler; + }, + }; + apply(ctx); + const model = "deepseek-v4-pro"; + const go = findModelSpecOn("go", model)?.cost?.input; + const zen = findModelSpecOn("zen", model)?.cost?.input; + // The case only means something while the planes disagree. + expect(go).toBeTypeOf("number"); + expect(zen).toBeTypeOf("number"); + expect(go).not.toBe(zen); + + const runTurn = async (provider: string, sessionId: string) => { + const down = async function* down() { + yield { + type: "usage", + usage: { + inputTokens: 1_000_000, + outputTokens: 0, + totalTokens: 1_000_000, + }, + }; + }; + const result: unknown = streamHandler?.( + { model, provider, sessionId }, + () => down() + ); + if (!isAsyncIterableLike(result)) { + throw new Error("expected async iterable downstream"); + } + await collectUnknown(result); + return getSessionUsage(sessionId)?.costUsd; + }; + + expect(await runTurn("opencode-go", "plane-go")).toBeCloseTo(go ?? 0, 6); + expect(await runTurn("opencode", "plane-zen")).toBeCloseTo(zen ?? 0, 6); + }); + it("ignores non-opencode providers and invalid session IDs", () => { let streamHandler: | ((options: unknown, next: () => unknown) => unknown) @@ -1137,3 +1259,59 @@ describe("apply (plugin lifecycle)", () => { expect(await collectUnknown(result)).toEqual(["subagent-chunk"]); }); }); + +describe("mountInternalRoutes", () => { + const config = resolveConfig({}); + const settle = async (): Promise => { + await new Promise((resolve) => { + setImmediate(resolve); + }); + }; + + it("contains a rejected mount instead of leaking an unhandled rejection", async () => { + // A `void` IIFE with no catch turned any bridge failure into an unhandled + // rejection, which Node's default mode answers by exiting the process. + const warn = vi.fn(); + const ctx = { logger: { warn } } as unknown as CordisContext; + const release = mountInternalRoutes(ctx, config, () => + Promise.reject(new Error("DUPLICATE_ADAPTER")) + ); + await settle(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("internal routes did not mount"), + expect.objectContaining({ message: "DUPLICATE_ADAPTER" }) + ); + expect(() => { + release(); + }).not.toThrow(); + }); + + it("withdraws a mount that settles after its release", async () => { + let resolveMount: (stop: () => void) => void = () => {}; + const stop = vi.fn<() => void>(); + const release = mountInternalRoutes( + {} as CordisContext, + config, + () => + new Promise((resolve) => { + resolveMount = resolve; + }) + ); + release(); + resolveMount(stop); + await settle(); + expect(stop).toHaveBeenCalledTimes(1); + }); + + it("withdraws a settled mount once, however often it is released", async () => { + const stop = vi.fn(); + const release = mountInternalRoutes({} as CordisContext, config, () => + Promise.resolve(stop) + ); + await settle(); + expect(stop).not.toHaveBeenCalled(); + release(); + release(); + expect(stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/manifest.test.ts b/test/manifest.test.ts index 7c8b284d..73c590d0 100644 --- a/test/manifest.test.ts +++ b/test/manifest.test.ts @@ -11,7 +11,14 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_PROVIDERS, name as PLUGIN_NAME } from "../src/index.ts"; +import { + CARD_FIELDS, + Config, + CONFIG_ONLY_FIELDS, + DEFAULT_PROVIDERS, + name as PLUGIN_NAME, + NS, +} from "./support/src.ts"; import { isRecord } from "./test-helpers.ts"; afterEach(() => { @@ -45,15 +52,8 @@ describe("bundle manifest consistency", () => { it("keeps the settings namespace equal to the cordis row id", async () => { const patch = await readText("cordis.patch.yml"); expect(patch).toContain("id: dsh-opencode-patch"); - // Read the NS constant textually: importing settings-page.tsx would - // drag the React + ui-primitives runtime chain (whose own deps are - // incomplete for node) into a hermetic suite. - const page = await readText("src/settings-page.tsx"); - const ns = /^export const NS = "([^"]+)";/m.exec(page); - if (ns === null || ns[1] === undefined) { - throw new Error("NS constant not found in src/settings-page.tsx"); - } - expect(ns[1]).toBe("dsh-opencode-patch"); + // The constant the client bundle ships, not its spelling in the source. + expect(NS).toBe("dsh-opencode-patch"); }); it("claims every route the layer mounts, in the shipped row", async () => { @@ -107,3 +107,94 @@ describe("bundle manifest consistency", () => { expect(banner[1]).toBe(pkgRaw.name); }); }); + +/** + * The keys `cordis.patch.yml` documents in its header and sets in its row. + * + * The header lists one key per line as `# key: type description`, with + * continuation lines indented deeper, so a key is exactly three spaces after + * the `#`. The card list runs from the line naming `CARD_FIELDS` to the one + * naming `CONFIG_ONLY_FIELDS`, the config-only list from there to the row. The + * row's keys sit eight spaces in under `config:`; its list items sit ten in and + * start with `-`, so they never read as keys. + */ +const manifestKeys = ( + text: string +): { card: string[]; configOnly: string[]; set: string[] } => { + const lines = text.split("\n"); + const insert = lines.findIndex((line) => line.startsWith("- insert:")); + const cardStart = lines.findIndex((line) => line.includes("CARD_FIELDS")); + const configOnlyStart = lines.findIndex((line) => + line.includes("CONFIG_ONLY_FIELDS") + ); + const configStart = lines.findIndex((line) => /^ +config:\s*$/u.test(line)); + if ( + insert === -1 || + cardStart === -1 || + configOnlyStart <= cardStart || + insert <= configOnlyStart || + configStart <= insert + ) { + throw new Error( + "cordis.patch.yml no longer has the layout this test reads" + ); + } + const keysOf = (slice: string[], pattern: RegExp): string[] => + slice + .map((line) => pattern.exec(line)?.[1]) + .filter((key): key is string => key !== undefined); + const documented = /^# {3}([A-Za-z]\w*):/u; + return { + card: keysOf(lines.slice(cardStart, configOnlyStart), documented), + configOnly: keysOf(lines.slice(configOnlyStart, insert), documented), + set: keysOf(lines.slice(configStart + 1), /^ {8}([A-Za-z]\w*):/u), + }; +}; + +describe("cordis.patch.yml agrees with the schema and the field registry", () => { + const root = path.dirname(import.meta.dirname); + const read = async () => + manifestKeys(await readFile(path.join(root, "cordis.patch.yml"), "utf-8")); + // `Config.dict` is the schema's own field table; `Config({})` would omit + // every key with neither a default nor `.volatile()`. + const schemaKeys = Object.keys(Config.dict ?? {}); + + it("documents and sets only keys the schema has", async () => { + const { card, configOnly, set } = await read(); + // Known answers, so a layout change fails loudly rather than vacuously. + expect(schemaKeys).toContain("debugFile"); + expect(set).toContain("providers"); + expect(card).toContain("injectUserAgent"); + + const unknown = [...card, ...configOnly, ...set].filter( + (key) => !schemaKeys.includes(key) + ); + expect(unknown).toEqual([]); + }); + + it("documents exactly the card fields and exactly the config-only fields", async () => { + const { card, configOnly } = await read(); + expect(card).toContain("injectUserAgent"); + expect(configOnly).toContain("providers"); + + expect(card.toSorted()).toEqual( + CARD_FIELDS.map((entry) => entry.field).toSorted() + ); + expect(configOnly.toSorted()).toEqual( + CONFIG_ONLY_FIELDS.map((entry) => entry.field).toSorted() + ); + }); + + it("documents every schema key exactly once", async () => { + const { card, configOnly } = await read(); + const documented = [...card, ...configOnly]; + expect(new Set(documented).size).toBe(documented.length); + expect(documented.toSorted()).toEqual(schemaKeys.toSorted()); + }); + + it("sets each row key at most once", async () => { + const { set } = await read(); + expect(set).toContain("providers"); + expect(new Set(set).size).toBe(set.length); + }); +}); diff --git a/test/models-discovery.test.ts b/test/models-discovery.test.ts index 9391972f..e13d3e64 100644 --- a/test/models-discovery.test.ts +++ b/test/models-discovery.test.ts @@ -362,6 +362,28 @@ describe("models-discovery · an empty list from a route that declares models", stop?.(); }); + it("passes a provider it does not claim through untouched", async () => { + // A direct `anthropic` or `openai` route is the deployment's, not this + // plugin's. Its ids are not in the OpenCode catalog's routing, and the + // servability filter used to drop them as "unservable". + const foreign = [ + { id: "claude-opus-9", name: "Claude Opus 9", provider: "anthropic" }, + { id: "gpt-7", name: "GPT-7", provider: "openai" }, + ]; + const ctx = { + llm: { + listProviders: () => [], + listConfigurableProviders: () => [], + listModels: async (provider: string) => + foreign.filter((row) => row.provider === provider), + }, + } as unknown as CordisContext; + const stop = hideResponsesRoute(ctx, resolveConfig()); + expect(await ctx.llm?.listModels?.("anthropic")).toEqual([foreign[0]]); + expect(await ctx.llm?.listModels?.("openai")).toEqual([foreign[1]]); + stop?.(); + }); + it("leaves an adapter's own answer alone", async () => { const ctx = { llm: { @@ -1131,6 +1153,38 @@ describe("models-discovery: the Effort row a model reports", () => { stop?.(); }); + it("leaves a provider it does not claim to its own adapter", async () => { + // The catalog is OpenCode's. A foreign route whose adapter refuses an id the + // catalog happens to share must keep its own failure, and one that answers + // must keep its own limits rather than OpenCode's. + const own = { + context: { contextWindow: 200_000 }, + id: "claude-sonnet-4-5", + }; + const ctx = { + llm: { + resolveModelInfo: async (provider: string) => { + if (provider === "anthropic-broken") { + throw new Error("adapter refused"); + } + return own; + }, + }, + } as never as CordisContext; + const stop = decorateModelInfo(ctx, enrichConfig); + await expect( + patched(ctx)("anthropic-broken", "claude-sonnet-4-5") + ).rejects.toThrow("adapter refused"); + expect(await patched(ctx)("anthropic", "claude-sonnet-4-5")).toBe(own); + // The claimed route still answers from the catalog, so the guard is not + // simply switching the decorator off. + const claimed = (await patched(ctx)("opencode", "claude-sonnet-4-5")) as { + context?: { contextWindow?: number }; + }; + expect(claimed.context?.contextWindow).not.toBe(200_000); + stop?.(); + }); + it("turns a NON-RECORD answer into a catalog record, so the group survives", async () => { // The quieter half of the same hazard. The Host reads `resolved.reasoning` // immediately after `resolveModelInfo` returns, so an `undefined` answer is a diff --git a/test/node-engine.test.ts b/test/node-engine.test.ts new file mode 100644 index 00000000..918cbf10 --- /dev/null +++ b/test/node-engine.test.ts @@ -0,0 +1,78 @@ +/** + * The engine guard the whole suite runs behind. + * + * Exercised with versions whose answer is known in both directions, so a guard + * that silently passed everything would fail here rather than look healthy. + * + * @module test/node-engine.test + */ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import setup, { engineRefusal, floorMajorOf } from "./support/node-engine.ts"; + +describe("node-engine guard", () => { + it("reads a single >= floor and nothing else", () => { + expect(floorMajorOf(">=24")).toBe(24); + expect(floorMajorOf(" >= 24.3.1 ")).toBe(24); + expect(floorMajorOf("^24")).toBeUndefined(); + expect(floorMajorOf(">=24 <27")).toBeUndefined(); + expect(floorMajorOf(24)).toBeUndefined(); + }); + + it("refuses a Node below the floor and names both versions", () => { + const refusal = engineRefusal("22.22.0", ">=24"); + expect(refusal).toContain("22.22.0"); + expect(refusal).toContain(">=24"); + }); + + it("admits the floor itself and anything above it", () => { + expect(engineRefusal("24.0.0", ">=24")).toBeUndefined(); + expect(engineRefusal("26.12.1", ">=24")).toBeUndefined(); + }); + + it("reports a range it cannot read instead of admitting it", () => { + expect(engineRefusal("26.0.0", "^24")).toMatch(/cannot read/u); + }); + + it("guards against the package's real floor, which this process satisfies", () => { + // The input is the actual package.json, so a guard pointed at the wrong file + // or the wrong field fails here instead of admitting every run. + const pkg = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8") + ) as { engines: { node: string } }; + expect(floorMajorOf(pkg.engines.node)).toBeGreaterThanOrEqual(24); + expect(() => { + setup(); + }).not.toThrow(); + }); + + it("names the Node CI runs in .node-version, at or above the floor", () => { + const pinned = Number( + readFileSync(new URL("../.node-version", import.meta.url), "utf8").trim() + ); + const ci = readFileSync( + new URL("../.github/workflows/ci.yml", import.meta.url), + "utf8" + ); + expect(engineRefusal(`${pinned}.0.0`, ">=24")).toBeUndefined(); + expect(ci).toContain(`node-version: ${pinned}`); + }); +}); + +describe("offline guard", () => { + it("is installed, refuses a public host and admits loopback", async () => { + const { hostOf, offlineFetch } = await import("./support/offline.ts"); + // Installed for this file, the way the config promises. + expect(globalThis.fetch).toBe(offlineFetch); + await expect( + offlineFetch("https://models.opencode.ai/api.json") + ).rejects.toThrow( + /offline test suite: refused a request to models\.opencode\.ai/u + ); + expect(hostOf(new URL("http://127.0.0.1:1/x"))).toBe("127.0.0.1"); + expect(hostOf(new Request("http://localhost/x"))).toBe("localhost"); + expect(hostOf("not a url")).toBe(""); + }); +}); diff --git a/test/opencode-store.test.ts b/test/opencode-store.test.ts index 551add3d..977f912c 100644 --- a/test/opencode-store.test.ts +++ b/test/opencode-store.test.ts @@ -125,6 +125,25 @@ describe("opencode-store: credentials", () => { expect(found?.[0]?.value).toBe("oc_sk_fixture_new"); }); + it("falls back to legacy rows per integration, not across the table", () => { + // A flagged Go row is the Go selection only. Read across the table, it hid + // the legacy Zen row and the reader answered with no Zen key at all. + const path = database((db) => { + schema(db); + addCredential(db, "go", "opencode-go", "oc_sk_fixture_go", 1); + db.prepare( + "INSERT INTO credential (id, integration_id, label, value, active, time_updated) VALUES (?, ?, ?, ?, NULL, ?)" + ).run("legacy", "opencode", "Zen", "oc_sk_fixture_zen_legacy", 100); + }); + const found = readOpencodeCredentials(path); + expect( + found?.map((row) => `${row.integrationId}=${row.value}`).toSorted() + ).toEqual([ + "opencode-go=oc_sk_fixture_go", + "opencode=oc_sk_fixture_zen_legacy", + ]); + }); + it("extracts the key from the JSON every real row carries", () => { // Measured against a live installation: EVERY value is a JSON document, not a // bare key. Returning the document puts JSON in the Bearer header, and the diff --git a/test/pi-provider.test.ts b/test/pi-provider.test.ts index ac1c7513..f0bffeae 100644 --- a/test/pi-provider.test.ts +++ b/test/pi-provider.test.ts @@ -13,6 +13,7 @@ import { BASE_FOR_PROTOCOL, baseForProtocol, piModelFor, + planeBaseFor, protocolFor, thinkingLevelMapFor, } from "../src/pi-provider.ts"; @@ -275,9 +276,9 @@ describe("pi-provider · the tables themselves", () => { } }); - it("resolves each endpoint family to its own base, and auto to the catalog", () => { - // `auto` is the catalog's own address and therefore cannot be wrong about - // which family is current; the other two pin one, which is the whole point + it("resolves each endpoint family to its own base, and auto as modern", () => { + // `auto` is an alias of `modern` (the copy says so, test/endpoint-copy); + // `legacy` pins the older single base per plane, which is the whole point // of the setting — the two families are separately entitled. expect(baseForProtocol("opencode", "openai-completions", "auto")).toBe( OPENCODE_PLANE_API.zen @@ -301,6 +302,19 @@ describe("pi-provider · the tables themselves", () => { ); }); + it("resolves auto exactly as modern on every protocol and plane", () => { + for (const plane of ["opencode", "opencode-go"] as const) { + expect(planeBaseFor(plane, "auto")).toBe(planeBaseFor(plane, "modern")); + const protocols = Object.keys(BASE_FOR_PROTOCOL[plane]); + expect(protocols.length).toBe(4); + for (const protocol of protocols) { + expect(baseForProtocol(plane, protocol, "auto"), protocol).toBe( + baseForProtocol(plane, protocol, "modern") + ); + } + } + }); + it("keeps the Anthropic base one level shorter on every family", () => { for (const family of ENDPOINT_FAMILIES) { for (const plane of ["opencode", "opencode-go"] as const) { @@ -333,7 +347,7 @@ describe("pi-provider · the tables themselves", () => { expect( piModelFor("opencode", model, "openai-completions", {}, "modern").baseUrl ).toBe(OPENCODE_PLANE_API.zen); - // No family named means the catalog's own address, as before. + // No family named means the default, auto, which resolves as modern. expect( piModelFor("opencode", model, "openai-completions", {}).baseUrl ).toBe(OPENCODE_PLANE_API.zen); diff --git a/test/plane-divergence.test.ts b/test/plane-divergence.test.ts index c1aac635..300ce887 100644 --- a/test/plane-divergence.test.ts +++ b/test/plane-divergence.test.ts @@ -7,16 +7,19 @@ * real catalogue disagrees about the PRICES today (two models), and agrees * about everything else, so a test built on the real catalogue cannot tell a * plane-aware lookup from a plane-blind one: both pass. That is the gap this - * file closes, using `test/fixtures/catalogs.ts` to build the row. + * file closes, using `test/support/catalogs.ts` to build the row. * * @module test/plane-divergence.test */ import { describe, expect, it } from "vitest"; -import { calculateTurnCost, recordTurnUsage } from "../src/session-cost.ts"; -import { coreToolDecision } from "../src/tool-fallback.ts"; -import { divergent, spec } from "./fixtures/catalogs.ts"; +import { divergent, spec } from "./support/catalogs.ts"; +import { + calculateTurnCost, + coreToolDecision, + recordTurnUsage, +} from "./support/src.ts"; describe("the free flag is the plane's row", () => { it("injects the free tier's tools on the plane that says free, and not on the other", () => { diff --git a/test/plane-lookup.test.ts b/test/plane-lookup.test.ts index acc4932c..38fbf173 100644 --- a/test/plane-lookup.test.ts +++ b/test/plane-lookup.test.ts @@ -10,23 +10,26 @@ * the shipped one: a `models.dev` payload → the parser → the active maps → the * lookup. Nothing here reaches a synthetic consumer of the plugin's internals. * - * **The installation is per FILE, not per test.** `refreshCatalog` merges by id - * and cannot remove rows, so a synthetic world persists for the rest of this - * file — which is why this file holds nothing else, and why its rows carry ids - * no other test names. + * **The installation is per TEST.** `refreshCatalog` merges by id and cannot + * remove rows, but the suite's setup file (`test/support/reset-process-state.ts`) + * resets the live catalog after every test, so a world installed once per file + * would be gone by the second case. Installing before each one keeps every case + * independent of the order it runs in; the rows still carry ids no other test + * names. * * @module test/plane-lookup.test */ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { payload } from "./support/catalogs.ts"; import { + coreToolDecision, findModelSpec, findModelSpecOn, + isCoreToolModel, refreshCatalog, -} from "../src/models-catalog.ts"; -import { coreToolDecision, isCoreToolModel } from "../src/tool-fallback.ts"; -import { payload } from "./fixtures/catalogs.ts"; +} from "./support/src.ts"; /** A model the two planes describe differently: free on Go, paid on Zen. */ const DIVERGENT = "acme-divergent-scratchpad"; @@ -50,14 +53,7 @@ const install = async (): Promise => { await refreshCatalog(async () => Response.json(body), true); }; -beforeAll(install, 15_000); - -/** Other suites in this process must not see the synthetic rows. */ -afterAll(() => { - // Nothing to undo: `refreshCatalog` has no removal, and the rows are named so - // that no other fixture collides with them. The next process gets the shim. - expect(DIVERGENT.length).toBeGreaterThan(0); -}); +beforeEach(install, 15_000); describe("the lookup reads the plane's own row", () => { it("parses the divergent world into two rows that disagree", () => { diff --git a/test/probe-verdict.test.ts b/test/probe-verdict.test.ts index f05c7ad6..0388a58a 100644 --- a/test/probe-verdict.test.ts +++ b/test/probe-verdict.test.ts @@ -79,6 +79,26 @@ describe("a retirement", () => { // the case this rule exists for (`gemini-3-flash` was listed and refused). expect(retiredAtTheGateway({ status: 200, summary: "ok" })).toBe(false); }); + + it("is an upstream's withdrawal too, but only when a 400 says so", () => { + // Measured on a free model the catalog still listed: the gateway relays the + // upstream's answer under a 400, the same status a malformed request gets. + const withdrawn = { + status: 400, + summary: "Upstream request failed: Model is unavailable", + }; + expect(retiredAtTheGateway(withdrawn)).toBe(true); + expect(usableAsSubject(withdrawn)).toBe(false); + // A 400 for any other reason is the request's fault, and a case about the + // route has to see it rather than walk past it. + const malformed = { status: 400, summary: "Invalid request body" }; + expect(retiredAtTheGateway(malformed)).toBe(false); + expect(usableAsSubject(malformed)).toBe(true); + // The words under another status are not this shape. + expect( + retiredAtTheGateway({ status: 500, summary: "Model is unavailable" }) + ).toBe(false); + }); }); describe("the busy gateway", () => { diff --git a/test/remote-contract.test.ts b/test/remote-contract.test.ts index 4b0efc19..2be741a0 100644 --- a/test/remote-contract.test.ts +++ b/test/remote-contract.test.ts @@ -24,35 +24,110 @@ import { patchRemote } from "../src/settings-page.tsx"; const SRC = join(import.meta.dirname, "..", "src"); +/** Every `export const NAME = "value"` in `src/`, so a constant resolves. */ +const exportedStrings = (): Map => { + const constants = new Map(); + for (const file of readdirSync(SRC).filter((name) => name.endsWith(".ts"))) { + const source = readFileSync(join(SRC, file), "utf8"); + for (const match of source.matchAll( + /export const ([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]+)"/gu + )) { + if (match[1] !== undefined && match[2] !== undefined) { + constants.set(match[1], match[2]); + } + } + } + return constants; +}; + +/** One remote service the host mounts: its file, class and namespace. */ +interface HostService { + readonly file: string; + readonly className: string; + readonly namespace: string; +} + /** - * Every namespace the host mounts a remote service under. + * Every remote service the host mounts, with the namespace it mounts under. * * Read from the source rather than imported: the services publish their - * namespace through `super(ctx, "name")`, and instantiating them needs a cordis - * context. A grep over the tree is the honest measurement of "what does the host - * mount", and it fails loudly if the pattern ever changes. + * namespace through `super(ctx, name)`, and instantiating them needs a cordis + * context. The name may be a literal or an exported constant, and both resolve: + * matching only the literal let `opencodeStore` — passed as `STORE_NAMESPACE` — + * through with no client descriptor, and the card's inspection row could never + * answer. */ -const hostNamespaces = (): string[] => { - const found = new Set(); +const hostServices = (): HostService[] => { + const constants = exportedStrings(); + const services: HostService[] = []; for (const file of readdirSync(SRC).filter((name) => name.endsWith(".ts"))) { const source = readFileSync(join(SRC, file), "utf8"); - for (const match of source.matchAll( - /super\(\s*ctx\s+as\s+never\s*,\s*"([^"]+)"\s*\)/gu - )) { - if (match[1] !== undefined) { - found.add(match[1]); - } + const className = /export class (\w+) extends TypertRemoteService/u.exec( + source + )?.[1]; + const argument = + /super\(\s*ctx\s+as\s+never\s*,\s*("([^"]+)"|[A-Z_][A-Z0-9_]*)\s*\)/u.exec( + source + ); + if (className === undefined || argument === null) { + continue; } + const namespace = + argument[2] ?? + constants.get(argument[1] ?? "") ?? + `unresolved:${argument[1]}`; + services.push({ className, file, namespace }); } - return [...found].toSorted(); + return services.toSorted((a, b) => a.namespace.localeCompare(b.namespace)); }; +const hostNamespaces = (): string[] => + hostServices().map((service) => service.namespace); + +/** The prototype key `remoteMethods()` reads, as the services spell it. */ +const REMOTE_METHOD_DESCRIPTOR_KEY = + "@deepseek-ai/dsh-typert-protocol/remote-methods"; + describe("remote contract · host and client agree", () => { it("finds the host namespaces, so the check is not vacuous", () => { // A grep that matches nothing is indistinguishable from a clean result; // these two are the services this plugin mounts today. expect(hostNamespaces()).toContain("opencodeGoUsage"); expect(hostNamespaces()).toContain("opencodeModelPurge"); + expect(hostNamespaces()).toContain("opencodeStore"); + expect( + hostNamespaces().filter((name) => name.startsWith("unresolved:")) + ).toEqual([]); + }); + + it("registers a descriptor for every Remote METHOD each service declares", async () => { + // Per method, not per namespace: a namespace with one descriptor and a + // second declared method would pass the namespace check and still leave + // the second unreachable. + const declared = new Set( + patchRemote.descriptors.map((d) => `${d.service}.${d.method}`) + ); + const missing: string[] = []; + let methods = 0; + for (const service of hostServices()) { + const module = (await import(join(SRC, service.file))) as Record< + string, + { prototype: Record } + >; + const marker = module[service.className]?.prototype[ + REMOTE_METHOD_DESCRIPTOR_KEY + ] as { methods?: readonly { method: string }[] } | undefined; + for (const { method } of marker?.methods ?? []) { + methods += 1; + if (!declared.has(`${service.namespace}.${method}`)) { + missing.push(`${service.namespace}.${method}`); + } + } + } + // Every service declares at least one method, so a marker read that found + // nothing would show here rather than as a clean pass. + expect(methods).toBeGreaterThanOrEqual(hostServices().length); + expect(missing).toEqual([]); }); it("registers a descriptor for every namespace the host mounts", () => { diff --git a/test/request-shape.test.ts b/test/request-shape.test.ts index e2a14ed6..c663d3b9 100644 --- a/test/request-shape.test.ts +++ b/test/request-shape.test.ts @@ -9,17 +9,55 @@ */ import { describe, expect, it } from "vitest"; +import { catalogPlaneForRoute } from "../src/models-catalog.ts"; +import { INTERNAL_ROUTES } from "../src/responses-routes.ts"; import { ANTHROPIC_VERSION, authHeaderFor, gatewayPlaneFor, headerSourceOf, isGoPlaneUrl, + isZenProvider, modelIdOf, requestUrlOf, toolNamesOf, } from "../src/utils/request-shape.ts"; +describe("request-shape · isZenProvider", () => { + it("request-shape · isZenProvider: the Zen Google route is Zen, the Go routes are Go", () => { + // `opencode-google` is the Zen plane's Google route. It starts with + // `opencode-go`, so a predicate that looked for `go` anywhere read it as the + // subscription plan; the plane is `opencode-go` or one of its own + // `opencode-go-*` routes, and nothing else. + expect(isZenProvider("opencode-google")).toBe(true); + expect(isZenProvider("opencode-go-google")).toBe(false); + expect(isZenProvider("opencode-go-responses")).toBe(false); + expect(isZenProvider("opencode-go-anthropic")).toBe(false); + expect(isZenProvider("opencode-go")).toBe(false); + expect(isZenProvider("OpenCode-Go")).toBe(false); + expect(isZenProvider("opencode")).toBe(true); + expect(isZenProvider("OPENCODE")).toBe(true); + expect(isZenProvider("opencode-anthropic")).toBe(true); + expect(isZenProvider("opencode-responses")).toBe(true); + expect(isZenProvider("deepseek")).toBe(false); + expect(isZenProvider()).toBe(false); + }); + + it("agrees with catalogPlaneForRoute on every route the plugin serves", () => { + // The defect was these two predicates disagreeing; a route added to or + // renamed in responses-routes.ts must not let them drift apart again. The + // set is bounded on purpose: outside OpenCode routes the two differ by + // design (isZenProvider wants an `opencode` name; the plane defaults). + const routes = ["opencode", "opencode-go", ...INTERNAL_ROUTES]; + expect(routes.length).toBeGreaterThan(2); + for (const route of routes) { + expect(isZenProvider(route), route).toBe( + catalogPlaneForRoute(route) === "zen" + ); + } + }); +}); + describe("request-shape · the plane of a path", () => { it("reads the Go plane out of both endpoint families", () => { // The Go plane moved to the `inference` family, and three places ask this diff --git a/test/responses-provider.test.ts b/test/responses-provider.test.ts index 0a0efa65..a004ad21 100644 --- a/test/responses-provider.test.ts +++ b/test/responses-provider.test.ts @@ -24,6 +24,7 @@ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; import { describe, expect, it, vi } from "vitest"; import { OPENCODE_PLANE_API } from "../src/catalog-data.ts"; +import { resolveConfig } from "../src/config.ts"; import { RESPONSES_SDK, ROUTE_FOR_PLANE_PROTOCOL, @@ -1808,6 +1809,73 @@ describe("Go Google bridge mounting", () => { expect(host.adapters.has("opencode-go-google")).toBe(false); }); + it("hands back the bridges' disposer when the internal routes are off", async () => { + // The bridge mounts before the switch is read, so an exit that answered + // `undefined` here left both Google routes registered with nothing able to + // withdraw them — and the next reload's bridge then failed DUPLICATE_ADAPTER. + const host = createHost(); + const stop = await registerResponsesProvider( + host.ctx, + resolveConfig({ internalRoutes: false }) + ); + expect(host.adapters.has("opencode-google")).toBe(true); + expect(stop).toBeTypeOf("function"); + stop?.(); + expect(host.adapters.has("opencode-google")).toBe(false); + expect(host.adapters.has("opencode-go-google")).toBe(false); + // And a second activation can mount them again, which is what a reload does. + const again = await registerResponsesProvider( + host.ctx, + resolveConfig({ internalRoutes: false }) + ); + expect(host.adapters.has("opencode-google")).toBe(true); + again?.(); + }); + + it("withdraws routes and disposes the fiber when the mount fails to start", async () => { + // A route the mounted plugin registered before its startup error is live on + // the host. The failure path used to log "internal routes stay unregistered" + // and leave both the route and the fiber behind. + const host = createHost(); + const disposed = vi.fn(); + type Ctx = NonNullable; + const wrap = (ctx: Ctx): Ctx => { + const isolate = ctx.isolate as (name: string) => Ctx; + const plugin = ctx.plugin as ( + subject: unknown, + config?: unknown + ) => unknown; + Object.assign(ctx, { + isolate(this: Ctx, name: string) { + return wrap(isolate.call(this, name)); + }, + plugin(this: Ctx, subject: unknown, config?: unknown) { + plugin.call(this, subject, config); + return { + await: () => Promise.reject(new Error("startup failed")), + dispose: disposed, + }; + }, + }); + return ctx; + }; + wrap(host.ctx); + const stop = await registerResponsesProvider(host.ctx); + // Fiber disposal is fire-and-forget; let it run. + await Promise.resolve(); + expect(disposed).toHaveBeenCalledTimes(1); + // Only the two bridges remain; every route the failed mount took is gone. + expect( + ( + (host.ctx.llm?.listProviders?.() ?? []) as readonly { id: string }[] + ).map((provider) => provider.id) + ).toEqual(["opencode-google", "opencode-go-google"]); + // The bridges did mount, and the disposer handed back still withdraws them. + expect(host.adapters.has("opencode-google")).toBe(true); + stop?.(); + expect(host.adapters.has("opencode-google")).toBe(false); + }); + it("withdraws the first bridge if mounting the second fails", async () => { const host = createHost(); const llm = host.ctx.llm!; diff --git a/test/retry.test.ts b/test/retry.test.ts index 3699125a..74d43f0c 100644 --- a/test/retry.test.ts +++ b/test/retry.test.ts @@ -1,17 +1,33 @@ import { describe, expect, it, vi } from "vitest"; -import { retryUntilAnswered } from "../src/utils/retry.ts"; +import { + backoffOver, + retryUntilAnswered, + type RetryOptions, + type RetryTimer, +} from "./support/src.ts"; describe("utils/retry: retryUntilAnswered", () => { const noSleep = () => Promise.resolve(); + const run = ( + read: () => Promise, + attempts: number, + extra: Partial = {} + ): Promise => + retryUntilAnswered(read, { + attempts, + sleep: noSleep, + ...extra, + }); + it("returns the first answer without retrying when immediately defined", async () => { let calls = 0; const read = () => { calls += 1; return Promise.resolve("ready"); }; - const result = await retryUntilAnswered(read, 5, noSleep); + const result = await run(read, 5); expect(result).toBe("ready"); expect(calls).toBe(1); }); @@ -22,7 +38,7 @@ describe("utils/retry: retryUntilAnswered", () => { calls += 1; return Promise.resolve(calls < 3 ? undefined : "finally"); }; - const result = await retryUntilAnswered(read, 5, noSleep); + const result = await run(read, 5); expect(result).toBe("finally"); expect(calls).toBe(3); }); @@ -39,7 +55,7 @@ describe("utils/retry: retryUntilAnswered", () => { } return Promise.resolve("recovered"); }; - const result = await retryUntilAnswered(read, 5, noSleep); + const result = await run(read, 5); expect(result).toBe("recovered"); expect(calls).toBe(3); }); @@ -49,28 +65,48 @@ describe("utils/retry: retryUntilAnswered", () => { const read = async () => { calls += 1; }; - const result = await retryUntilAnswered(read, 4, noSleep); + const result = await run(read, 4); expect(result).toBeUndefined(); expect(calls).toBe(4); }); - it("returns undefined and logs a warning when all attempts throw errors", async () => { + it("returns undefined and tells the caller's logger when all attempts throw errors", async () => { let calls = 0; - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warn = vi.fn<(message: string, error: unknown) => void>(); const read = () => { calls += 1; return Promise.reject(new Error("repeated outage")); }; + const result = await run(read, 3, { warn }); + expect(result).toBeUndefined(); + expect(calls).toBe(3); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "[dsh-opencode-patch] retry: all attempts failed", + expect.any(Error) + ); + }); + + it("stays silent when every attempt throws and no logger is supplied", async () => { + // Silence is the claim, so the console is watched: a fallback to + // `console.warn` when `warn` is omitted would answer the same result. + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); try { - const result = await retryUntilAnswered(read, 3, noSleep); - expect(result).toBeUndefined(); - expect(calls).toBe(3); - expect(warnSpy).toHaveBeenCalledWith( - "[dsh-opencode-patch] retry: all attempts failed", - expect.any(Error) - ); + let calls = 0; + const read = () => { + calls += 1; + return Promise.reject(new Error("repeated outage")); + }; + await expect(run(read, 2)).resolves.toBeUndefined(); + expect(calls).toBe(2); + expect(consoleWarn).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); } finally { - warnSpy.mockRestore(); + consoleWarn.mockRestore(); + consoleError.mockRestore(); } }); @@ -81,7 +117,7 @@ describe("utils/retry: retryUntilAnswered", () => { calls += 1; return "unreached"; }; - const result = await retryUntilAnswered(read, 5, sleep, () => false); + const result = await run(read, 5, { live: () => false, sleep }); expect(result).toBeUndefined(); expect(calls).toBe(0); expect(sleep).not.toHaveBeenCalled(); @@ -94,20 +130,82 @@ describe("utils/retry: retryUntilAnswered", () => { calls += 1; alive = false; }; - const result = await retryUntilAnswered(read, 5, noSleep, () => alive); + const result = await run(read, 5, { live: () => alive }); expect(result).toBeUndefined(); expect(calls).toBe(1); }); - it("uses the default sleep calculation when sleep is omitted", async () => { - // Testing that the default sleep parameter can be executed without error + it("defaults to five attempts", async () => { + let calls = 0; + const read = async () => { + calls += 1; + }; + const result = await retryUntilAnswered(read, { sleep: noSleep }); + expect(result).toBeUndefined(); + expect(calls).toBe(5); + }); + + it("waits on the schedule backoffOver builds over the caller's timer", async () => { + // No wall clock: the timer runs its callback at once and records the delay + // it was asked for, so the default schedule is observed rather than waited. let calls = 0; + const delays: number[] = []; + const immediateTimer: RetryTimer = (callback, delayMs) => { + delays.push(delayMs); + callback(); + }; const read = () => { calls += 1; return Promise.resolve(calls < 2 ? undefined : "done"); }; - const result = await retryUntilAnswered(read, 2); + const result = await retryUntilAnswered(read, { + attempts: 2, + sleep: backoffOver(immediateTimer), + }); expect(result).toBe("done"); expect(calls).toBe(2); + expect(delays).toEqual([300]); + }); +}); + +describe("utils/retry: backoffOver", () => { + /** A timer that records each request and runs nothing until told to. */ + const recordingTimer = () => { + const scheduled: { callback: () => void; delayMs: number }[] = []; + const timer: RetryTimer = (callback, delayMs) => { + scheduled.push({ callback, delayMs }); + }; + return { scheduled, timer }; + }; + + it("schedules a linear backoff and resolves only when the timer fires", async () => { + const { scheduled, timer } = recordingTimer(); + const sleep = backoffOver(timer); + const settled: number[] = []; + const pending = [0, 1, 2].map(async (attempt) => { + await sleep(attempt); + settled.push(attempt); + }); + + expect(scheduled.map((entry) => entry.delayMs)).toEqual([300, 600, 900]); + await Promise.resolve(); + expect(settled).toEqual([]); + + scheduled[1]?.callback(); + await pending[1]; + expect(settled).toEqual([1]); + + scheduled[0]?.callback(); + scheduled[2]?.callback(); + await Promise.all(pending); + expect(settled.toSorted((a, b) => a - b)).toEqual([0, 1, 2]); + }); + + it("takes its base delay as a parameter", () => { + const { scheduled, timer } = recordingTimer(); + const sleep = backoffOver(timer, 100); + void sleep(0); + void sleep(1); + expect(scheduled.map((entry) => entry.delayMs)).toEqual([100, 200]); }); }); diff --git a/test/session-cost.test.ts b/test/session-cost.test.ts index e7fc45b6..62089062 100644 --- a/test/session-cost.test.ts +++ b/test/session-cost.test.ts @@ -261,6 +261,16 @@ describe("getSessionUsage", () => { expect(getSessionUsage("nope")?.totalTokens).toBe(9); }); + it("falls back to the session that ran a turn most recently, not the newest one", () => { + // The meter asks with no id, so this fallback IS its reading. Ordered by + // creation, a turn in an older session kept showing the newer one's spend. + recordTurnUsage("a", { inputTokens: 1, totalTokens: 1 }, GO_FLASH, "m"); + recordTurnUsage("b", { inputTokens: 9, totalTokens: 9 }, GO_FLASH, "m"); + expect(getSessionUsage()?.totalTokens).toBe(9); + recordTurnUsage("a", { inputTokens: 1, totalTokens: 1 }, GO_FLASH, "m"); + expect(getSessionUsage()?.totalTokens).toBe(2); + }); + it("returns a copy that cannot mutate stored state", () => { recordTurnUsage("a", { inputTokens: 1, totalTokens: 1 }, GO_FLASH, "m"); const first = getSessionUsage("a"); diff --git a/test/settings-card-purge.test.tsx b/test/settings-card-purge.test.tsx index 3d1073d1..500ea342 100644 --- a/test/settings-card-purge.test.tsx +++ b/test/settings-card-purge.test.tsx @@ -328,6 +328,36 @@ describe("settings-card: the states that only exist after an interaction", () => }); await settle(); expect(host.textContent).toContain(en.purgeFailed); + // In the host's warning TEXT colour: a refusal read in caption grey is + // indistinguishable from a success that removed nothing. + const line = [...host.querySelectorAll("div")].find((d) => + (d.textContent ?? "").startsWith(en.purgeFailed) + ); + expect(line?.style.color).toBe("var(--dsw-alias-state-warn-label)"); + } finally { + cleanup(); + } + }); + + it("shows an outcome that is not a refusal in caption text", async () => { + const { act, host, settle, cleanup } = await mountCard({ + purgeDeprecated: async () => ({ + kind: "purged", + result: { catalogRead: true, removed: [] }, + }), + }); + try { + const button = [...host.querySelectorAll("button")].find((b) => + (b.textContent ?? "").includes(en.purgeAction) + ); + act(() => { + button?.click(); + }); + await settle(); + const line = [...host.querySelectorAll("div")].find((d) => + (d.textContent ?? "").startsWith(en.purgeNothing) + ); + expect(line?.style.color).toBe("var(--dsw-alias-label-caption)"); } finally { cleanup(); } diff --git a/test/settings-page.test.tsx b/test/settings-page.test.tsx index c4567cfa..560d8b66 100644 --- a/test/settings-page.test.tsx +++ b/test/settings-page.test.tsx @@ -3,21 +3,21 @@ import assert from "node:assert/strict"; import { describe, expect, it, vi } from "vitest"; import { + applySettingsPage as apply, + CARD_FIELDS, + Config, + CONFIG_ONLY_FIELDS, + en, ENDPOINT_FAMILIES, KEY_SOURCE_POLICIES, -} from "../src/config-values.ts"; -import { Config } from "../src/index.ts"; -import { en } from "../src/settings-copy.ts"; -import { CARD_FIELDS, CONFIG_ONLY_FIELDS } from "../src/settings-fields.ts"; -import { - apply, LEGACY_NS, - SCOPED_PKG, NS, PKG, + remoteNamespace, + remoteNamespaceOf, + SCOPED_PKG, SPECS, -} from "../src/settings-page.tsx"; -import { remoteNamespace, remoteNamespaceOf } from "../src/settings-remote.ts"; +} from "./support/src.ts"; import { elementName, findAll, @@ -1219,8 +1219,14 @@ describe("settings-page: field specs", () => { // The card deliberately hides the override knobs, so the two lists must // together cover every schema key exactly once. A knob in neither would be // unreachable; a knob in both would contradict CONFIG_ONLY_FIELDS. + // + // The keys come from `Config.dict`, the schema's own field table. + // `Config({})` materialises only the keys that carry a default or are + // `.volatile()`, which is how `debugFile` (neither) went missing from both + // lists without this test noticing. // oxlint-disable-next-line unicorn/no-array-sort -- `Object.keys` returns a fresh array, so in-place sort mutates nothing shared. - const schemaKeys = Object.keys(Config({})).sort(); + const schemaKeys = Object.keys(Config.dict ?? {}).sort(); + expect(schemaKeys).toContain("debugFile"); const cardFields = CARD_FIELDS.map((entry) => entry.field); const configOnly = CONFIG_ONLY_FIELDS.map((entry) => entry.field); const covered = [...cardFields, ...configOnly]; @@ -1317,8 +1323,9 @@ describe("settings-page: every remote gets a mount", () => { // Namespaces, not descriptors: `opencodeGoUsage` carries more than one // endpoint (`read`, and the account-scoped `readAccount` the sibling // `dsh-opencode-go` account page calls), and both are the same mount. + // `opencodeStore` is the inspection row's; it was the one left out. expect(new Set(services)).toEqual( - new Set(["opencodeGoUsage", "opencodeModelPurge"]) + new Set(["opencodeGoUsage", "opencodeModelPurge", "opencodeStore"]) ); expect(mounted).toHaveLength(1); expect(mounted[0]?.package).toBe("dsh-opencode-patch"); diff --git a/test/settings-purge.test.ts b/test/settings-purge.test.ts index 0b34a917..0be6a2a5 100644 --- a/test/settings-purge.test.ts +++ b/test/settings-purge.test.ts @@ -50,6 +50,33 @@ describe("purgeDeprecatedModels", () => { }); }); +describe("purgeDeprecatedModels: the remote protocol's refusal", () => { + it("carries the host's refusal envelope in its own words", async () => { + // The remote protocol answers a failure as `{ ok: false, error }`, with + // `error` a serialized RemoteError: the reason is its `message`. + await expect( + purgeDeprecatedModels( + scopeWith(async () => ({ + error: new Error("no config editor on this host"), + ok: false, + })) + ) + ).resolves.toEqual({ + kind: "failed", + message: "no config editor on this host", + }); + }); + + it("names a refusal that carries no message", async () => { + await expect( + purgeDeprecatedModels(scopeWith(async () => ({ error: {}, ok: false }))) + ).resolves.toEqual({ + kind: "failed", + message: "the host refused the purge", + }); + }); +}); + describe("purgeDeprecatedModels: the paths a scope can take", () => { it("reads the namespace through ctx.get, the path that works at runtime", () => { // The `scope.remote.opencodeModelPurge` property read in the cases above is diff --git a/test/settings-store-inspect.test.tsx b/test/settings-store-inspect.test.tsx index db97239d..fc959318 100644 --- a/test/settings-store-inspect.test.tsx +++ b/test/settings-store-inspect.test.tsx @@ -269,4 +269,50 @@ describe("settings-store-inspect: the row", () => { host.remove(); } }); + + it("renders a problem note in the host's warning text colour", async () => { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + act(() => { + root.render( + createElement(StoreInspectRow, { + onInspect: () => + Promise.resolve({ kind: "unavailable" }), + t, + }) + ); + }); + const button = [...host.querySelectorAll("button")].find((b) => + (b.textContent ?? "").includes(t("storeInspect")) + ); + act(() => { + button?.click(); + }); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + + // The innermost div whose whole text is the note: with no rows, the + // report's wrapper carries the same text. + const note = [...host.querySelectorAll("div")].find( + (d) => + d.textContent === t("storeInspectUnavailable") && + d.querySelector("div") === null + ); + expect(note, "the note must be rendered").toBeDefined(); + expect(note?.style.color).toBe("var(--dsw-alias-state-warn-label)"); + } finally { + act(() => { + root.unmount(); + }); + host.remove(); + } + }); }); diff --git a/test/settings-store.test.ts b/test/settings-store.test.ts index 8ff980d8..ab9517c2 100644 --- a/test/settings-store.test.ts +++ b/test/settings-store.test.ts @@ -58,6 +58,43 @@ describe("inspectStore", () => { }); }); + it("keeps only the whitelisted fields, so a stray secret cannot reach the card", async () => { + // The seam's rule is "a credential never crosses it". The parser is what + // enforces that on this side: a field the host did not mean to send is + // dropped, not rendered. + const answer = { + account: { + accessToken: "fixture-token-not-real", + email: "someone@example.com", + type: "oauth", + }, + found: true, + token: "fixture-token-not-real", + }; + const outcome = await inspectStore(scopeWith(async () => answer)); + expect(outcome).toEqual({ + inspection: { + account: { email: "someone@example.com", type: "oauth" }, + found: true, + }, + kind: "inspected", + }); + expect(JSON.stringify(outcome)).not.toContain("fixture-token-not-real"); + }); + + it("reports the remote's own failure from an ok:false envelope", async () => { + // Read as a shape, this used to surface as "invalid inspection shape" — a + // message about the envelope instead of the host's reason. + const inspect = vi.fn(async () => ({ + error: { code: "opencode/store-unavailable", message: "no sqlite here" }, + ok: false, + })); + await expect(inspectStore(scopeWith(inspect))).resolves.toEqual({ + kind: "failed", + message: "no sqlite here", + }); + }); + it("rejects an answer that is not an inspection", async () => { // A shape check, not a wish: the card renders rows off this value, and a // half-built answer would print `undefined` beside a label. diff --git a/test/settings-usage.test.tsx b/test/settings-usage.test.tsx index 5e86a3c9..e6d3fb3b 100644 --- a/test/settings-usage.test.tsx +++ b/test/settings-usage.test.tsx @@ -8,7 +8,7 @@ import { render, waitFor } from "@testing-library/react"; */ import type { ReactNode } from "react"; import React from "react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { zh } from "../src/settings-copy.ts"; // @vitest-environment jsdom @@ -358,4 +358,40 @@ describe("settings-usage: reading until the remote answers", () => { ).toBeUndefined(); expect(calls).toBe(1); }); + + it("waits 300 ms on the real timer when no sleep is supplied", async () => { + // The production card passes no `sleep`, so this is the clock it runs on. + // A timer that never fired would leave the card refreshing for good. + vi.useFakeTimers(); + try { + let calls = 0; + const read = () => { + calls += 1; + return Promise.resolve(calls < 2 ? undefined : answered); + }; + const pending = readUntilAnswered(read, 2); + await vi.advanceTimersByTimeAsync(299); + expect(calls).toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(calls).toBe(2); + expect(await pending).toBe(answered); + } finally { + vi.useRealTimers(); + } + }); + + it("reports on the console when every attempt fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const read = () => Promise.reject(new Error("repeated outage")); + expect(await readUntilAnswered(read, 2, noSleep)).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "[dsh-opencode-patch] retry: all attempts failed", + expect.any(Error) + ); + } finally { + warn.mockRestore(); + } + }); }); diff --git a/test/stream-hook.test.ts b/test/stream-hook.test.ts index 9134b22b..52a61339 100644 --- a/test/stream-hook.test.ts +++ b/test/stream-hook.test.ts @@ -16,9 +16,22 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { projectNameFrom } from "../src/stream-hook.ts"; +import { + GO_GOOGLE_INTERNAL_ROUTE, + projectNameFrom, + recordDebug, + RESPONSES_ROUTE, +} from "./support/src.ts"; +import { collectUnknown, createMockStream } from "./test-helpers.ts"; + +// The debug sink is observed rather than written: a negative assertion over a +// file needs a wait to mean anything, and a mock makes "never called" exact. +vi.mock("../src/debug.ts", async (importOriginal) => ({ + ...(await importOriginal()), + recordDebug: vi.fn(), +})); const created: string[] = []; @@ -113,9 +126,9 @@ describe("stream-hook · the pass-through guards", () => { * serve. */ const hookWith = async (providers: string[]) => { - const { createStreamHook } = await import("../src/stream-hook.ts"); + const { createStreamHook, resolveConfig } = + await import("./support/src.ts"); const { AsyncLocalStorage } = await import("node:async_hooks"); - const { resolveConfig } = await import("../src/config.ts"); return createStreamHook( { logger: { info: () => {} } } as never, resolveConfig({ providers, usageEnabled: false } as never), @@ -189,6 +202,228 @@ describe("stream-hook · the pass-through guards", () => { }); }); +describe("stream-hook · the redirect forwards the caller's request", () => { + /** + * A model the plane serves on another protocol is handed to the route that + * speaks it. The caller's request goes with it whole: only `provider` changes + * (and, for Go-Google, the history is replayed into Google's shape). + * + * `toEqual` deliberately: it ignores a key whose value is `undefined`, so the + * pin holds whether or not the spread carries one. + */ + const hookFor = async ( + providers: string[], + api: string, + route: string, + stream: (options: unknown) => unknown + ) => { + const { createStreamHook, resolveConfig } = + await import("./support/src.ts"); + const { AsyncLocalStorage } = await import("node:async_hooks"); + return createStreamHook( + { + llm: { adapters: new Map([[route, {}]]), stream }, + logger: { info: () => {} }, + } as never, + resolveConfig({ + modelRoutes: { "probe-model": { api } }, + providers, + usageEnabled: false, + } as never), + new AsyncLocalStorage() + ); + }; + + let warn: { mockRestore: () => void } | undefined; + + beforeEach(() => { + // The probe id is in no catalog, so the hook warns once that the picker + // would not offer it. That line is not what these cases are about. + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warn?.mockRestore(); + }); + + it("hands the whole request to the protocol route with only provider replaced", async () => { + const stream = vi.fn((_options: unknown) => "redirected"); + const hook = await hookFor( + ["opencode"], + "openai-responses", + RESPONSES_ROUTE, + stream + ); + const options = { + messages: [], + model: "probe-model", + provider: "opencode", + sessionId: "s", + }; + const next = vi.fn(() => "next-result"); + + expect(hook(options, next)).toBe("redirected"); + expect(next).not.toHaveBeenCalled(); + expect(stream).toHaveBeenCalledTimes(1); + expect(stream.mock.calls[0]?.[0]).toEqual({ + ...options, + provider: RESPONSES_ROUTE, + }); + }); + + it("hands a Go-Google request over with its history in Google's shape", async () => { + const sentinel = { notIterable: true }; + const stream = vi.fn((_options: unknown) => sentinel); + const hook = await hookFor( + ["opencode-go"], + "google-generative-ai", + GO_GOOGLE_INTERNAL_ROUTE, + stream + ); + // The user message is the control: only an assistant turn the Go plane's + // Google adapter answered carries a replay identity to translate. + const user = { content: "hi", role: "user" }; + const replayState = { + response: { + api: "google-generative-ai", + kind: "pi-ai", + provider: "opencode-go", + }, + signature: "sig", + }; + const assistant = { + content: "hello", + role: "assistant", + source: { provider: "opencode-go", replayState }, + }; + const options = { + messages: [user, assistant], + model: "probe-model", + provider: "opencode-go", + sessionId: "s", + }; + const next = vi.fn(() => "next-result"); + + expect(hook(options, next)).toBe(sentinel); + expect(next).not.toHaveBeenCalled(); + expect(stream).toHaveBeenCalledTimes(1); + // A literal expectation, not one re-derived through the function under + // test: forwarding the history raw must fail here. + expect(stream.mock.calls[0]?.[0]).toEqual({ + ...options, + messages: [ + user, + { + content: "hello", + role: "assistant", + source: { + provider: GO_GOOGLE_INTERNAL_ROUTE, + replayState: { + response: { + api: "google-generative-ai", + kind: "pi-ai", + provider: GO_GOOGLE_INTERNAL_ROUTE, + }, + signature: "sig", + }, + }, + }, + ], + provider: GO_GOOGLE_INTERNAL_ROUTE, + }); + }); +}); + +describe("stream-hook · the debug gate", () => { + /** + * Naming `debugFile` writes the file; `debug` governs the console alone. The + * request half (lifecycle.ts) and this stream half are the two halves of one + * JSONL log, so they share that gate: the README's own example, `debug: false` + * beside a `debugFile` path, must produce a file holding both halves. + */ + const debugFile = join(tmpdir(), "dsh-stream-debug", "stream.jsonl"); + + const hookWith = async ( + debug: boolean, + file: string | undefined, + info: (...args: unknown[]) => void + ) => { + const { createStreamHook, resolveConfig } = + await import("./support/src.ts"); + const { AsyncLocalStorage } = await import("node:async_hooks"); + return createStreamHook( + { logger: { info } } as never, + resolveConfig({ + debug, + ...(file === undefined ? {} : { debugFile: file }), + providers: ["opencode"], + usageEnabled: false, + } as never), + new AsyncLocalStorage() + ); + }; + + /** Drive one turn through the hook and drain it. */ + const turn = async ( + debug: boolean, + { + info = () => {}, + withFile = true, + }: { + info?: (...args: unknown[]) => void; + withFile?: boolean; + } = {} + ): Promise => { + const hook = await hookWith(debug, withFile ? debugFile : undefined, info); + const result = hook( + { model: "m", provider: "opencode", sessionId: "s" }, + () => createMockStream("x") + ); + await collectUnknown(result as AsyncIterable); + }; + + beforeEach(() => { + vi.mocked(recordDebug).mockClear(); + }); + + it("stream-hook · the debug gate: writes debugFile while debug is off, and logs nothing", async () => { + const info = vi.fn<(...args: unknown[]) => void>(); + await turn(false, { info }); + expect(recordDebug).toHaveBeenCalledTimes(1); + const [, file] = vi.mocked(recordDebug).mock.calls[0] ?? []; + expect(file).toBe(debugFile); + expect(info).not.toHaveBeenCalled(); + }); + + it("writes one entry for the turn to debugFile and logs it while debug is on", async () => { + const info = vi.fn<(...args: unknown[]) => void>(); + await turn(true, { info }); + expect(recordDebug).toHaveBeenCalledTimes(1); + const [, file, entry] = vi.mocked(recordDebug).mock.calls[0] ?? []; + expect(file).toBe(debugFile); + expect(entry).toMatchObject({ + header: "x-opencode-session", + session: "s", + }); + expect(info).toHaveBeenCalledTimes(1); + }); + + it("logs the turn but writes no file while debug is on without a debugFile", async () => { + const info = vi.fn<(...args: unknown[]) => void>(); + await turn(true, { info, withFile: false }); + expect(recordDebug).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledTimes(1); + expect(info.mock.calls[0]).toContain("x-opencode-session"); + }); + + it("does nothing at all with debug off and no debugFile", async () => { + const info = vi.fn<(...args: unknown[]) => void>(); + await turn(false, { info, withFile: false }); + expect(recordDebug).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + }); +}); + describe("stream-hook · a turn is priced at ITS plane's rate", () => { /** * The two planes price the same id differently, and the plane-agnostic @@ -199,9 +434,9 @@ describe("stream-hook · a turn is priced at ITS plane's rate", () => { * meter showed the half. */ const hookWith = async (providers: string[]) => { - const { createStreamHook } = await import("../src/stream-hook.ts"); + const { createStreamHook, resolveConfig } = + await import("./support/src.ts"); const { AsyncLocalStorage } = await import("node:async_hooks"); - const { resolveConfig } = await import("../src/config.ts"); return createStreamHook( { logger: { info: () => {} } } as never, resolveConfig({ providers, usageEnabled: true } as never), @@ -227,7 +462,7 @@ describe("stream-hook · a turn is priced at ITS plane's rate", () => { it("prices a Zen turn at the Zen rate, and a Go turn at the Go rate", async () => { const { clearSessionUsageStore, getSessionUsage } = - await import("../src/session-cost.ts"); + await import("./support/src.ts"); clearSessionUsageStore(); const hook = await hookWith(["opencode", "opencode-go"]); diff --git a/test/fixtures/catalogs.ts b/test/support/catalogs.ts similarity index 81% rename from test/fixtures/catalogs.ts rename to test/support/catalogs.ts index 673a906a..40e2bc5f 100644 --- a/test/fixtures/catalogs.ts +++ b/test/support/catalogs.ts @@ -17,10 +17,14 @@ * The prices DO disagree for two models, which is why the pricing decision was * testable against the real catalog and the others were not. * - * @module test/fixtures/catalogs + * It reaches `src` through the one door, `./src.ts`, like the other fixtures, + * so a module move edits one file. (The setup file cannot: it loads in the + * jsdom files too, so it names the defining modules directly.) + * + * @module test/support/catalogs */ -import type { CatalogModelSpec } from "../../src/catalog-data.ts"; +import { type CatalogModelSpec, refreshCatalog } from "./src.ts"; /** What a test says about one catalogue row, in the terms the decisions use. */ export interface SpecInput { @@ -168,3 +172,34 @@ export const payload = (world: { }); return { "opencode-go": provider(world.go), opencode: provider(world.zen) }; }; + +/** Free on Zen, paid on Go. Neither probe id contains "free", so only the row decides. */ +export const ZEN_FREE_PROBE = "plane-probe"; + +/** Free on Go, paid on Zen. */ +export const GO_FREE_PROBE = "go-probe"; + +/** + * Refresh the live catalogue with the two plane-divergent probes. + * + * A request-path test needs the world installed where the shipped lookup reads + * it, so this goes through `refreshCatalog` (the stubbed fetch never touches the + * network). `refreshCatalog` merges into the live maps, and + * `test/support/reset-process-state.ts` restores the shim after every test, so + * a case seeds its own world. + * + * @returns once the live maps carry both probes. + */ +export const seedDivergentCatalog = async (): Promise => { + const body = payload({ + go: [ + { free: true, id: GO_FREE_PROBE, name: "Go Probe" }, + { id: ZEN_FREE_PROBE, name: "Plane Probe" }, + ], + zen: [ + { id: GO_FREE_PROBE, name: "Go Probe" }, + { free: true, id: ZEN_FREE_PROBE, name: "Plane Probe" }, + ], + }); + await refreshCatalog(async () => Response.json(body), true); +}; diff --git a/test/support/node-engine.ts b/test/support/node-engine.ts new file mode 100644 index 00000000..6e0cfd1c --- /dev/null +++ b/test/support/node-engine.ts @@ -0,0 +1,75 @@ +/** + * Refuse to run the suite on a Node the package does not support. + * + * `package.json` declares `engines.node`, and several cases depend on what that + * floor guarantees: `node:sqlite` unflagged, `Intl.DurationFormat`, and ICU data + * new enough to agree with the strings the tests pin. Below the floor those cases + * do not fail for a reason anyone could act on — they fail as a bundling error in + * an unrelated jsdom file and as a unit word that differs by one character, which + * reads as a regression in the code under test. + * + * So the run stops before collecting a single file and names the real cause. + * The floor is read from `package.json`, not restated, so raising it there is the + * whole of raising it here. + * + * @module test/support/node-engine + */ +import { readFileSync } from "node:fs"; + +/** + * The lowest major a `>=N` range admits, or `undefined` for any other shape. + * + * Deliberately narrow: `engines.node` is a single `>=` floor in this package, + * and a range this cannot read is reported rather than guessed at. + * + * @param range - the `engines.node` value. + * @returns the floor's major version. + */ +export const floorMajorOf = (range: unknown): number | undefined => { + if (typeof range !== "string") { + return undefined; + } + const match = /^\s*>=\s*(\d+)(?:\.\d+){0,2}\s*$/u.exec(range); + return match?.[1] === undefined ? undefined : Number(match[1]); +}; + +/** + * Why this Node cannot run the suite, or `undefined` when it can. + * + * @param version - `process.versions.node`. + * @param range - `engines.node` from `package.json`. + * @returns a message naming the version found and the floor required. + */ +export const engineRefusal = ( + version: string, + range: unknown +): string | undefined => { + const floor = floorMajorOf(range); + if (floor === undefined) { + return `engines.node is ${JSON.stringify(range)}, which this guard cannot read; expected a single ">=N" floor`; + } + const major = Number(version.split(".")[0]); + if (!Number.isFinite(major) || major < floor) { + return ( + `this suite needs Node ${String(range)} (package.json engines.node) and is running on ${version}. ` + + "Below the floor, cases fail for engine reasons — a missing node:sqlite or Intl.DurationFormat — " + + "that read like regressions. Switch Node (the version CI uses is in .node-version) and re-run." + ); + } + return undefined; +}; + +/** Vitest `globalSetup`: runs once, before any test file is collected. */ +export default function setup(): void { + const pkg: unknown = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8") + ); + const range = + typeof pkg === "object" && pkg !== null + ? (pkg as { engines?: { node?: unknown } }).engines?.node + : undefined; + const refusal = engineRefusal(process.versions.node, range); + if (refusal !== undefined) { + throw new Error(refusal); + } +} diff --git a/test/support/offline.ts b/test/support/offline.ts new file mode 100644 index 00000000..8b9070db --- /dev/null +++ b/test/support/offline.ts @@ -0,0 +1,54 @@ +/** + * Keep the unit and mock suites off the network. + * + * Both are documented as offline, and they were not: every `getLive*Catalog` + * read on a stale catalog — which is every read in a fresh test process — + * starts a background revalidation through `globalThis.fetch`. Measured: 54 + * requests to `models.opencode.ai` per unit run. Where the runner has network, + * one that lands mid-test replaces the catalog under the case reading it, so + * a result depended on what the vendor published that minute. + * + * A request to anything but loopback is refused here. The background refresh + * already degrades to the bundled shim on a failed fetch, so nothing else + * changes; a case that MEANS to fetch stubs `fetch` itself, and the mock suite + * talks to a loopback server, which stays allowed. The e2e config leaves this + * file out, because reaching the gateway is its whole point. + * + * @module test/support/offline + */ +const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +/** The host a fetch input targets, or `""` when it is not an absolute URL. */ +export const hostOf = (input: RequestInfo | URL): string => { + let url: string; + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.href; + } else { + ({ url } = input); + } + try { + return new URL(url).hostname; + } catch { + return ""; + } +}; + +const online = globalThis.fetch; + +/** The guard, exported so a test can check it refuses what it should. */ +export const offlineFetch = async ( + input: RequestInfo | URL, + init?: RequestInit +): Promise => { + const host = hostOf(input); + if (!LOOPBACK.has(host)) { + throw new TypeError( + `offline test suite: refused a request to ${host || "a non-URL input"}; stub fetch in the case that needs it` + ); + } + return online(input, init); +}; + +globalThis.fetch = offlineFetch as typeof fetch; diff --git a/test/support/opencode-store-fixture.ts b/test/support/opencode-store-fixture.ts new file mode 100644 index 00000000..684f5311 --- /dev/null +++ b/test/support/opencode-store-fixture.ts @@ -0,0 +1,131 @@ +/** + * A real OpenCode credential database, for the host-side store readers. + * + * Kept out of `test-helpers.ts` on purpose: that module is shared with the + * jsdom (client) tests, and a `node:sqlite` import there drags a host-only + * built-in into every client test's module graph. The split is the same one + * `src/` keeps between the store reader and the client bundle. + * + * @module test/support/opencode-store-fixture + */ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import nodePath from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +/** One row of the installation's `credential` table, as a fixture. */ +export interface FixtureCredential { + /** The integration the credential belongs to: `opencode`, `opencode-go`, … */ + integrationId: string; + /** The credential's own name; defaults to the integration id. */ + label?: string; + /** The secret. A fixture shaped like one, never a real credential. */ + value: string; + /** `1` for the credential in use, `0` for one the user already replaced. */ + active?: number; +} + +/** One row of the installation's `account` table, plus the state that owns it. */ +export interface FixtureAccount { + /** The access token. A fixture shaped like one, never a real credential. */ + accessToken: string; + /** The account's email; not a secret, and not required. */ + email?: string; + /** The org the account is active in, written to `account_state`. */ + orgId?: string; + /** The refresh token, when the fixture should carry one. */ + refreshToken?: string; + /** + * The stored expiry, in epoch milliseconds. + * + * Deliberately a knob: the plugin uses a stored account whatever this says, + * and a fixture has to be able to put it in the past to prove that. + */ + tokenExpiry?: number; +} + +/** A credential store the test owns, and the way to point the reader at it. */ +export interface FixtureStore { + /** Set this as `$XDG_DATA_HOME`: the reader resolves its path from it. */ + root: string; + /** Remove the whole root. Call from `afterEach`. */ + cleanup: () => void; +} + +/** + * Build an OpenCode credential database in a temporary XDG data root. + * + * The reader resolves its own path from the environment, so a fixture is not a + * mock: this writes the real schema to a real SQLite file and hands back the + * root to export, which is the only thing that makes the store this test's + * rather than whatever the machine happens to have installed. + * + * @param credentials - the rows to insert, in order. + * @param account - the stored login, when the fixture should have one. + * @returns the data root and its cleanup. + */ +export const createOpencodeStore = ( + credentials: readonly FixtureCredential[], + account?: FixtureAccount +): FixtureStore => { + const root = mkdtempSync( + nodePath.join(os.tmpdir(), "opencode-store-fixture-") + ); + const directory = nodePath.join(root, "opencode"); + mkdirSync(directory, { recursive: true }); + const database = new DatabaseSync(nodePath.join(directory, "opencode.db")); + database.exec(` + CREATE TABLE credential ( + id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, + connector_id TEXT, method_id TEXT, active INTEGER, + time_created INTEGER, time_updated INTEGER + ); + CREATE TABLE account ( + id TEXT PRIMARY KEY, email TEXT, url TEXT, access_token TEXT, + refresh_token TEXT, token_expiry INTEGER, + time_created INTEGER, time_updated INTEGER + ); + CREATE TABLE account_state ( + id INTEGER PRIMARY KEY, active_account_id TEXT, active_org_id TEXT + ); + `); + const insert = database.prepare( + "INSERT INTO credential (id, integration_id, label, value, active) VALUES (?, ?, ?, ?, ?)" + ); + for (const [index, credential] of credentials.entries()) { + insert.run( + `fixture-${index}`, + credential.integrationId, + credential.label ?? credential.integrationId, + credential.value, + credential.active ?? 1 + ); + } + if (account !== undefined) { + database + .prepare( + "INSERT INTO account (id, email, url, access_token, refresh_token, token_expiry) VALUES (?, ?, ?, ?, ?, ?)" + ) + .run( + "fixture-account", + account.email ?? "you@example.com", + "https://opencode.ai/console", + account.accessToken, + account.refreshToken ?? "", + account.tokenExpiry ?? null + ); + // The org lives in its own table, and only the active row is read. + database + .prepare( + "INSERT INTO account_state (id, active_account_id, active_org_id) VALUES (?, ?, ?)" + ) + .run(1, "fixture-account", account.orgId ?? null); + } + database.close(); + return { + cleanup: () => { + rmSync(root, { force: true, recursive: true }); + }, + root, + }; +}; diff --git a/test/support/reset-process-state.ts b/test/support/reset-process-state.ts new file mode 100644 index 00000000..781347b1 --- /dev/null +++ b/test/support/reset-process-state.ts @@ -0,0 +1,30 @@ +/** + * Reset the plugin's process-wide stores after every test. + * + * Vitest isolates FILES, not the tests inside one, and the plugin keeps three + * stores at module level for the life of the process: the keys the fetch patch + * captured, the per-session spend, and the live catalog. A case that leaves any + * of them behind makes the next case's answer depend on run order — a captured + * key turns a "no credential" case into a "credential resolved" one, a recorded + * session becomes the meter's fallback reading, and a fixture refresh changes + * every catalog count after it. Measured: with the suite shuffled, seeds 42 and + * 1234 failed three catalog cases on exactly that. + * + * `pnpm run test:shuffle` runs the suite in a random order to keep it that way. + * + * Imported through the same specifiers the tests use, so it clears the same + * module instances they read. + * + * @module test/support/reset-process-state + */ +import { afterEach } from "vitest"; + +import { clearCapturedApiKeys } from "../../src/key-capture.ts"; +import { resetCatalogState } from "../../src/models-catalog.ts"; +import { clearSessionUsageStore } from "../../src/session-cost.ts"; + +afterEach(() => { + clearCapturedApiKeys(); + clearSessionUsageStore(); + resetCatalogState(); +}); diff --git a/test/support/src.ts b/test/support/src.ts new file mode 100644 index 00000000..c810ea68 --- /dev/null +++ b/test/support/src.ts @@ -0,0 +1,442 @@ +/** + * The single indirection through which tests reach `src`. + * + * P2 shrinks the public barrel to `apply`, `Config`, `inject`, `name` and the + * config types, and P10a moves modules into subject folders. A test that names + * a `src` path breaks on every such move, so this file is the one place that + * knows where things live: each name is re-exported from the module that + * DEFINES it, never through the public barrel, so shrinking the barrel does not + * touch a test. + * + * Three kinds of import keep their direct paths: a `vi.mock("../src/x.ts")` + * target (vitest mocks by module id), and the architecture tests, which are + * about the `src` layout itself — for both, the path is their subject; and the + * jsdom (client) tests, which import the defining module directly, because this + * file re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and that + * must stay out of a client test. + * + * Importing this file loads the whole `src` graph, host and client, so a module + * that fails to load fails every test that comes through here, not only its own. + * + * Two names are defined twice under one name, so the second definition is + * aliased: the client bundle's `apply` (`applySettingsPage`) and the Go + * allowance formatter `formatUsd` (`formatAllowanceUsd`). + * + * The file is type-checked with the rest of `test/`, so `vp check` proves that + * every re-export resolves. + * + * @module test/support/src + */ + +// attachment-services.ts +export { attachmentResolvers } from "../../src/attachment-services.ts"; + +// catalog-data.ts +export { + OPENCODE_FAMILY_API, + OPENCODE_GO_CATALOG, + OPENCODE_PLANE_API, + OPENCODE_ZEN_CATALOG, +} from "../../src/catalog-data.ts"; +export type { CatalogModelSpec } from "../../src/catalog-data.ts"; + +// config-values.ts +export { + ALL_MODELS_MARKER, + DEFAULT_ENDPOINT_FAMILY, + DEFAULT_KEY_SOURCE, + DEFAULT_PROVIDERS, + ENDPOINT_FAMILIES, + KEY_SOURCE_POLICIES, + readBoolean, + readString, + readStringList, + unwrapNode, +} from "../../src/config-values.ts"; + +// config.ts +export { + Config, + CONFIG_DEFAULTS, + DEFAULT_FREE_MODEL_MARKER, + DEFAULT_USAGE_BASE_URL, + DEFAULT_USAGE_KEY_ENV, + resolveConfig, +} from "../../src/config.ts"; +export type { + ModelOverrideConfig, + PluginConfig, + ResolvedPluginConfig, +} from "../../src/config.ts"; + +// cordis-context.ts +export { + loaderOfContext, + readCredentialsResolver, + readEntryOptions, + readSessionMetaResolver, +} from "../../src/cordis-context.ts"; +export type { CordisContext } from "../../src/cordis-context.ts"; + +// debug.ts +export { recordDebug } from "../../src/debug.ts"; + +// fetch-patch.ts +export { + hasSessionHeader, + isOpenCodeRequest, + patchFetch, +} from "../../src/fetch-patch.ts"; +export type { FetchPatchHooks } from "../../src/fetch-patch.ts"; + +// go-discovery.ts +export { + declaredProviderModels, + discoverGoConfig, + effectiveGoKeyRef, + resolveGoApiKey, + resolveGoBaseURL, + resolveGoKeyForRef, + resolveRoutedKey, + resolveZenCreditInfo, + toGoBaseURL, +} from "../../src/go-discovery.ts"; +export type { DiscoveredGoConfig } from "../../src/go-discovery.ts"; + +// go-limits-data.ts +export { GO_MODEL_LIMITS } from "../../src/go-limits-data.ts"; + +// go-limits.ts +export { + allowanceFor, + formatUsd as formatAllowanceUsd, + GO_PLAN_TIERS, + normalizeModelLabel, + parseGoLimitsDoc, + WINDOW_SHARE, +} from "../../src/go-limits.ts"; + +// google-bridge.ts +export { + ambientAuthContext, + emptyCredentialStore, + GO_GOOGLE_BASE_URL, + googleProviderFor, + loadHostPi, + registerGoogleBridge, + resolveGatewayKey, +} from "../../src/google-bridge.ts"; +export type { GoogleBridgeInputs, PiRuntime } from "../../src/google-bridge.ts"; + +// identity.ts +export { + inject, + name, + OPENCODE_PATCH_USER_AGENT, + OPENCODE_USER_AGENT, +} from "../../src/identity.ts"; + +// key-capture.ts +export { + clearCapturedApiKeys, + extractApiKeyFromHeaders, + getCapturedApiKey, + isPlaceholderApiKey, + recordCapturedApiKey, + tierForRequest, +} from "../../src/key-capture.ts"; + +// lifecycle.ts +export { apply, mountInternalRoutes } from "../../src/lifecycle.ts"; + +// models-catalog.ts +export { + catalogPlaneForRoute, + enrichModelsResponse, + findModelSpec, + findModelSpecOn, + getLiveGoCatalog, + getLiveZenCatalog, + isDeprecatedModelOn, + isGoModelsListingUrl, + isModelsListingUrl, + parseModelsDevCatalog, + refreshCatalog, + resetCatalogState, + sanitizeModalities, +} from "../../src/models-catalog.ts"; + +// models-discovery.ts +export { + decorateModelDiscovery, + decorateModelInfo, + decoratePlaneAdapters, + hideResponsesRoute, + isRouteRegistered, + isServableModel, + mergeDiscoveredModels, + PI_THINKING_LEVELS, + resolveDiscoveryProvider, + resolveEffectiveContextWindow, +} from "../../src/models-discovery.ts"; + +// opencode-paths.ts +export { + opencodeDatabaseFile, + opencodeDatabasePath, + opencodeRoots, +} from "../../src/opencode-paths.ts"; + +// opencode-store-service.ts +export { OpencodeStoreService } from "../../src/opencode-store-service.ts"; + +// opencode-store.ts +export { + inspectOpencodeStore, + readOpencodeCredentials, + summarizeCredential, +} from "../../src/opencode-store.ts"; + +// pi-provider.ts +export { + BASE_FOR_PROTOCOL, + baseForProtocol, + piModelFor, + planeBaseFor, + protocolFor, + thinkingLevelMapFor, +} from "../../src/pi-provider.ts"; + +// purge-contract.ts +export { parsePurgeResult } from "../../src/purge-contract.ts"; + +// purge-service.ts +export { + ModelPurgeService, + stripRetiredReferences, +} from "../../src/purge-service.ts"; + +// responses-provider.ts +export { + inheritedCredentialRef, + loadPiAi, + loadPiAiAdapterHost, + loadPiAiModule, + modelsForSdk, + narrowToDeclared, + reasoningEffortsFor, + registerResponsesProvider, + undescribedDeclaredModels, + unroutedDeclaredModels, +} from "../../src/responses-provider.ts"; + +// responses-routes.ts +export { + ANTHROPIC_ROUTE, + ANTHROPIC_SDK, + GO_GOOGLE_INTERNAL_ROUTE, + GO_RESPONSES_ROUTE, + GOOGLE_INTERNAL_ROUTE, + GOOGLE_SDK, + INTERNAL_ROUTES, + internalRouteFor, + isServableSdk, + MISTRAL_SDK, + PROTOCOL_FOR_SDK, + RESPONSES_ROUTE, + RESPONSES_SDK, + ROUTE_FOR_PLANE_PROTOCOL, + ROUTE_FOR_PROTOCOL, + SELF_MOUNTED_ROUTES, + UNSERVED_PLANE_SDKS, + UNSERVED_SDKS, +} from "../../src/responses-routes.ts"; + +// session-cost.ts +export { + calculateTurnCost, + clearSessionUsageStore, + describeModel, + formatModelRate, + formatUsd, + getSessionUsage, + recordTurnUsage, +} from "../../src/session-cost.ts"; +export type { + ModelCostRate, + SessionUsageSnapshot, +} from "../../src/session-cost.ts"; + +// session.ts +export { + fallbackSessionId, + headerValueFor, + newOpenCodeMessageId, + OPENCODE_UA, + openCodeSessionIdFor, +} from "../../src/session.ts"; + +// settings-boolean-field.tsx +export { SettingsBooleanField } from "../../src/settings-boolean-field.tsx"; +export type { BooleanFieldProps } from "../../src/settings-boolean-field.tsx"; + +// settings-card.tsx +export { OpencodeCard, purgeLine } from "../../src/settings-card.tsx"; + +// settings-choice-field.tsx +export { SettingsChoiceField } from "../../src/settings-choice-field.tsx"; +export type { ChoiceFieldProps } from "../../src/settings-choice-field.tsx"; + +// settings-copy.ts +export { en, withBundleCopy, zh } from "../../src/settings-copy.ts"; + +// settings-field-shell.tsx +export { SettingsFieldShell } from "../../src/settings-field-shell.tsx"; +export type { FieldShellProps } from "../../src/settings-field-shell.tsx"; + +// settings-fields.ts +export { + CARD_FIELDS, + CONFIG_ONLY_FIELDS, + SPECS, +} from "../../src/settings-fields.ts"; + +// settings-page.tsx +export { + apply as applySettingsPage, + LEGACY_NS, + NS, + patchRemote, + PKG, + SCOPED_PKG, +} from "../../src/settings-page.tsx"; + +// settings-purge.ts +export { purgeDeprecatedModels } from "../../src/settings-purge.ts"; + +// settings-remote.ts +export { + remoteNamespace, + remoteNamespaceOf, +} from "../../src/settings-remote.ts"; + +// settings-store-inspect.tsx +export { + inspectLine, + StoreInspectRow, +} from "../../src/settings-store-inspect.tsx"; + +// settings-store.ts +export { inspectStore } from "../../src/settings-store.ts"; +export type { StoreInspectOutcome } from "../../src/settings-store.ts"; + +// settings-usage.tsx +export { + readGoUsage, + readUntilAnswered, + UsageSummary, + UsageSummaryView, +} from "../../src/settings-usage.tsx"; + +// stream-hook.ts +export { createStreamHook, projectNameFrom } from "../../src/stream-hook.ts"; + +// tool-fallback.ts +export { + coreToolDecision, + DUMMY_BASH_TOOL, + DUMMY_BASH_TOOL_FUNCTION, + DUMMY_READ_TOOL, + DUMMY_READ_TOOL_FUNCTION, + isCoreToolModel, + maybeInjectCoreTools, + RESPONSES_PATH, +} from "../../src/tool-fallback.ts"; + +// turn-store.ts +export { withStore } from "../../src/turn-store.ts"; +export type { ActiveTurnState } from "../../src/turn-store.ts"; + +// usage-contract.ts +export { parseGoUsage, parseUsageQuery } from "../../src/usage-contract.ts"; +export type { GoUsage } from "../../src/usage-contract.ts"; + +// usage-panel.tsx +export { UsagePanel, UsageTrigger } from "../../src/usage-panel.tsx"; +export type { + UsagePanelProps, + UsageTriggerProps, +} from "../../src/usage-panel.tsx"; + +// usage-pill.tsx +export { MIN_FEEDBACK_MS, UsagePill } from "../../src/usage-pill.tsx"; +export type { + ModelDirectoryState, + SnapshotStore, +} from "../../src/usage-pill.tsx"; + +// usage-ui.ts +export { + CIRCUMFERENCE, + CONSOLE_URL, + describeUsage, + formatRelativeReset, + getAffectingWindow, + getWindowColorFor, + GO_PLAN_URL, + matchesAny, + parseFailure, + resetLabel, + ringGeometry, + STYLES, +} from "../../src/usage-ui.ts"; +export type { RelativeReset } from "../../src/usage-ui.ts"; + +// usage.ts +export { GoUsageService } from "../../src/usage.ts"; + +// utils/google-replay.ts +export { + googleHistoryFor, + googleReplayFor, + googleReplayStream, +} from "../../src/utils/google-replay.ts"; + +// utils/guards.ts +export { + getAsyncIterator, + isAsyncIterableLike, + isAsyncIteratorLike, + isFetchFunction, + isFunctionLike, + isRecord, + isUnknownArray, +} from "../../src/utils/guards.ts"; + +// utils/request-shape.ts +export { + ANTHROPIC_VERSION, + authHeaderFor, + gatewayPlaneFor, + headerSourceOf, + isGoPlaneUrl, + isZenProvider, + modelIdOf, + requestUrlOf, + toolNamesOf, +} from "../../src/utils/request-shape.ts"; + +// utils/retry.ts +export { backoffOver, retryUntilAnswered } from "../../src/utils/retry.ts"; +export type { RetryOptions, RetryTimer } from "../../src/utils/retry.ts"; + +// utils/session-id.ts +export { + isOpenCodeMessageId, + isOpenCodeSessionId, + PARENT_SESSION_ALT_HEADER, + PARENT_SESSION_HEADER, + SESSION_AFFINITY_HEADER, + SESSION_HEADER, + sessionIdForRequest, +} from "../../src/utils/session-id.ts"; diff --git a/test/test-helpers.ts b/test/test-helpers.ts index ca421970..abd62dab 100644 --- a/test/test-helpers.ts +++ b/test/test-helpers.ts @@ -6,14 +6,10 @@ import assert from "node:assert/strict"; import { AsyncLocalStorage } from "node:async_hooks"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import os from "node:os"; -import nodePath from "node:path"; -import { DatabaseSync } from "node:sqlite"; import { setTimeout as sleep } from "node:timers/promises"; -import type { ActiveTurnState } from "../src/index.ts"; +import type { ActiveTurnState } from "./support/src.ts"; export const SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; @@ -178,125 +174,6 @@ export const createMockContext = () => reflect: { provide: () => disposeNoop }, }) as never; -/* ------------------------------------------------- OpenCode credential store */ - -/** One row of the installation's `credential` table, as a fixture. */ -export interface FixtureCredential { - /** The integration the credential belongs to: `opencode`, `opencode-go`, … */ - integrationId: string; - /** The credential's own name; defaults to the integration id. */ - label?: string; - /** The secret. A fixture shaped like one, never a real credential. */ - value: string; - /** `1` for the credential in use, `0` for one the user already replaced. */ - active?: number; -} - -/** One row of the installation's `account` table, plus the state that owns it. */ -export interface FixtureAccount { - /** The access token. A fixture shaped like one, never a real credential. */ - accessToken: string; - /** The account's email; not a secret, and not required. */ - email?: string; - /** The org the account is active in, written to `account_state`. */ - orgId?: string; - /** The refresh token, when the fixture should carry one. */ - refreshToken?: string; - /** - * The stored expiry, in epoch milliseconds. - * - * Deliberately a knob: the plugin uses a stored account whatever this says, - * and a fixture has to be able to put it in the past to prove that. - */ - tokenExpiry?: number; -} - -/** A credential store the test owns, and the way to point the reader at it. */ -export interface FixtureStore { - /** Set this as `$XDG_DATA_HOME`: the reader resolves its path from it. */ - root: string; - /** Remove the whole root. Call from `afterEach`. */ - cleanup: () => void; -} - -/** - * Build an OpenCode credential database in a temporary XDG data root. - * - * The reader resolves its own path from the environment, so a fixture is not a - * mock: this writes the real schema to a real SQLite file and hands back the - * root to export, which is the only thing that makes the store this test's - * rather than whatever the machine happens to have installed. - * - * @param credentials - the rows to insert, in order. - * @param account - the stored login, when the fixture should have one. - * @returns the data root and its cleanup. - */ -export const createOpencodeStore = ( - credentials: readonly FixtureCredential[], - account?: FixtureAccount -): FixtureStore => { - const root = mkdtempSync( - nodePath.join(os.tmpdir(), "opencode-store-fixture-") - ); - const directory = nodePath.join(root, "opencode"); - mkdirSync(directory, { recursive: true }); - const database = new DatabaseSync(nodePath.join(directory, "opencode.db")); - database.exec(` - CREATE TABLE credential ( - id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, - connector_id TEXT, method_id TEXT, active INTEGER, - time_created INTEGER, time_updated INTEGER - ); - CREATE TABLE account ( - id TEXT PRIMARY KEY, email TEXT, url TEXT, access_token TEXT, - refresh_token TEXT, token_expiry INTEGER, - time_created INTEGER, time_updated INTEGER - ); - CREATE TABLE account_state ( - id INTEGER PRIMARY KEY, active_account_id TEXT, active_org_id TEXT - ); - `); - const insert = database.prepare( - "INSERT INTO credential (id, integration_id, label, value, active) VALUES (?, ?, ?, ?, ?)" - ); - for (const [index, credential] of credentials.entries()) { - insert.run( - `fixture-${index}`, - credential.integrationId, - credential.label ?? credential.integrationId, - credential.value, - credential.active ?? 1 - ); - } - if (account !== undefined) { - database - .prepare( - "INSERT INTO account (id, email, url, access_token, refresh_token, token_expiry) VALUES (?, ?, ?, ?, ?, ?)" - ) - .run( - "fixture-account", - account.email ?? "you@example.com", - "https://opencode.ai/console", - account.accessToken, - account.refreshToken ?? "", - account.tokenExpiry ?? null - ); - // The org lives in its own table, and only the active row is read. - database - .prepare( - "INSERT INTO account_state (id, active_account_id, active_org_id) VALUES (?, ?, ?)" - ) - .run(1, "fixture-account", account.orgId ?? null); - } - database.close(); - return { - cleanup: () => { - rmSync(root, { force: true, recursive: true }); - }, - root, - }; -}; - /* ------------------------------------------------ React element tree helpers */ /** diff --git a/test/tool-fallback.test.ts b/test/tool-fallback.test.ts index 1a310ff9..d79ce118 100644 --- a/test/tool-fallback.test.ts +++ b/test/tool-fallback.test.ts @@ -17,18 +17,23 @@ import { describe, expect, it } from "vitest"; -import { ALL_MODELS_MARKER, DEFAULT_FREE_MODEL_MARKER } from "../src/index.ts"; import { + GO_FREE_PROBE, + seedDivergentCatalog, + ZEN_FREE_PROBE, +} from "./support/catalogs.ts"; +import { + ALL_MODELS_MARKER, + DEFAULT_FREE_MODEL_MARKER, DUMMY_BASH_TOOL, - DUMMY_BASH_TOOL_ANTHROPIC, DUMMY_BASH_TOOL_FUNCTION, DUMMY_READ_TOOL, - DUMMY_READ_TOOL_ANTHROPIC, DUMMY_READ_TOOL_FUNCTION, RESPONSES_PATH, + findModelSpecOn, isCoreToolModel, maybeInjectCoreTools, -} from "../src/tool-fallback.ts"; +} from "./support/src.ts"; import { parseJsonBody, toolNamesOf } from "./test-helpers.ts"; const RESPONSES_URL = `https://opencode.ai/zen/v1${RESPONSES_PATH}`; @@ -138,6 +143,23 @@ describe("maybeInjectCoreTools: bodies it must not rewrite", () => { } }); + it("leaves an Anthropic /messages body alone", () => { + // The gate admits only URLs naming `/responses` or `/completions`, and a + // gateway `/messages` URL names neither, so an Anthropic body never reaches + // the injector — which is why it carries no Anthropic schemas at all. + const headers = new Headers(); + const body = JSON.stringify({ model: FREE_MODEL }); + + const out = maybeInjectCoreTools( + "https://opencode.ai/inference/anthropic/v1/messages", + body, + headers, + options() + ); + expect(out).toBe(body); + expect(headers.get("content-length")).toBeNull(); + }); + it("injects on the completions route too", () => { const body = JSON.stringify({ input: "hi", model: FREE_MODEL }); const headers = new Headers(); @@ -459,28 +481,6 @@ describe("maybeInjectCoreTools: injection", () => { tools: [null, "bash", 7, declaredRead, DUMMY_BASH_TOOL], }); }); - - it("prefers the Anthropic schemas when the URL names /messages", () => { - // Both path segments in one URL is not a route this plugin documents: - // `RESPONSES_PATH` gates first, so a plain `/messages` URL never reaches - // the injector. The case pins the precedence between the two dialects - // (Anthropic wins over the OpenAI function form) and keeps the Anthropic - // schemas from rotting untested. - const url = "https://relay.internal/v1/messages?upstream=/responses"; - const headers = new Headers(); - const body = JSON.stringify({ model: FREE_MODEL }); - - const out = maybeInjectCoreTools(url, body, headers, options()); - - expect(parseJsonBody(bodyText(out))).toEqual({ - model: FREE_MODEL, - tools: [DUMMY_READ_TOOL_ANTHROPIC, DUMMY_BASH_TOOL_ANTHROPIC], - }); - // `input_schema`, not `parameters`: the two dialects are not - // interchangeable, and mixing them is the failure the fallback avoids. - expect(DUMMY_READ_TOOL_ANTHROPIC).toHaveProperty("input_schema"); - expect(DUMMY_READ_TOOL_FUNCTION).toHaveProperty("function"); - }); }); describe("maybeInjectCoreTools: marker scoping", () => { @@ -553,3 +553,63 @@ describe("maybeInjectCoreTools: marker scoping", () => { ]); }); }); + +describe("isCoreToolModel: the plane is the request's, with a plane-blind fallback", () => { + // The catalog's free flag is read from the plane the request is on. No id + // the shipped shim lists on both planes disagrees on it, so these cases seed + // a live catalog that does: a Go-first lookup reads the paid Go row for a + // model Zen serves free, and the free Go row for a model Zen charges for. + it("admits a model the request's plane calls free, whatever the other plane says", async () => { + await seedDivergentCatalog(); + + expect(isCoreToolModel(ZEN_FREE_PROBE, "", "zen")).toBe(true); + expect(isCoreToolModel(ZEN_FREE_PROBE, "", "go")).toBe(false); + expect(isCoreToolModel(GO_FREE_PROBE, "", "go")).toBe(true); + expect(isCoreToolModel(GO_FREE_PROBE, "", "zen")).toBe(false); + }); + + it("falls back to the plane-blind row when the plane is unknown or does not carry the id", () => { + // The id below is free on Zen and absent from the Go catalog. A Go request + // for it finds no Go row, so the plane-blind lookup answers — and that row + // is the Zen one, which says free. No seeding: this is the shipped shim. + // The premise, asserted: a regeneration that lists the id on Go would keep + // the three answers below true while the fallback stopped being taken. + expect(findModelSpecOn("go", "big-pickle")).toBeUndefined(); + expect(findModelSpecOn("zen", "big-pickle")?.is_free).toBe(true); + expect( + isCoreToolModel("big-pickle", DEFAULT_FREE_MODEL_MARKER, "zen") + ).toBe(true); + expect(isCoreToolModel("big-pickle", DEFAULT_FREE_MODEL_MARKER, "go")).toBe( + true + ); + expect(isCoreToolModel("big-pickle", DEFAULT_FREE_MODEL_MARKER)).toBe(true); + }); + + it("maybeInjectCoreTools reads the plane from the URL's path", async () => { + await seedDivergentCatalog(); + const body = JSON.stringify({ model: ZEN_FREE_PROBE }); + + const zenHeaders = new Headers(); + const zen = maybeInjectCoreTools(RESPONSES_URL, body, zenHeaders, { + enabled: true, + modelMarker: "none", + }); + expect(parseJsonBody(bodyText(zen))).toEqual({ + model: ZEN_FREE_PROBE, + tools: [DUMMY_READ_TOOL, DUMMY_BASH_TOOL], + }); + expect(zenHeaders.get("content-length")).toBe( + String(Buffer.byteLength(bodyText(zen))) + ); + + const goHeaders = new Headers(); + const go = maybeInjectCoreTools( + `https://opencode.ai/zen/go/v1${RESPONSES_PATH}`, + body, + goHeaders, + { enabled: true, modelMarker: "none" } + ); + expect(go).toBe(body); + expect(goHeaders.get("content-length")).toBeNull(); + }); +}); diff --git a/test/usage-pill.test.tsx b/test/usage-pill.test.tsx index 734a633f..e643b3de 100644 --- a/test/usage-pill.test.tsx +++ b/test/usage-pill.test.tsx @@ -141,7 +141,8 @@ describe("usage-pill: helper functions & calculations", () => { formatRelativeReset(isoAt(ms), locale).text; expect(at(ahead((2 * 86400 + 4 * 3600) * 1000), "en")).toBe("2d 4h"); expect(at(ahead((2 * 86400 + 4 * 3600) * 1000), "zh")).toBe("2天4小时"); - expect(at(30 * 60 * 1000 + 500, "zh")).toBe("30分"); + expect(at(30 * 60 * 1000 + 500, "zh")).toBe("30分钟"); + expect(at(ahead((3 * 3600 + 15 * 60) * 1000), "zh")).toBe("3小时15分钟"); expect(at(ahead(2 * 86400 * 1000), "en")).toBe("2d"); expect(at(ahead(45 * 60 * 1000), "en")).toBe("45m"); } finally { @@ -149,9 +150,10 @@ describe("usage-pill: helper functions & calculations", () => { Reflect.set(Intl, "DurationFormat", original); } } - // Read through `Reflect`: this project's `lib` is ES2024, which predates - // the constructor, so `Intl.DurationFormat` is not a typed property. - expect(typeof Reflect.get(Intl, "DurationFormat")).toBe("function"); + // The engine is left as it was found: restored when it shipped the + // constructor, still absent when it did not. Asserting `"function"` here + // instead would test the engine, not the cleanup. + expect(Reflect.get(Intl, "DurationFormat")).toBe(original); }); it("renders an absolute reset in Chinese with 点/分, not HH:mm", () => { @@ -187,13 +189,18 @@ describe("usage-pill: helper functions & calculations", () => { "45m" ); expect(formatRelativeReset(isoAt(ahead(45 * 60 * 1000)), "zh").text).toBe( - "45分" + "45分钟" ); } finally { + // Restored either way: on an engine without the constructor, leaving the + // stand-in installed would make every later case run against it. if (had) { Reflect.set(Intl, "DurationFormat", original); + } else { + Reflect.deleteProperty(Intl, "DurationFormat"); } } + expect(Reflect.get(Intl, "DurationFormat")).toBe(original); }); it("localizes the duration units, not just the label", () => { diff --git a/test/usage-service.test.ts b/test/usage-service.test.ts index 46653ba7..3290f207 100644 --- a/test/usage-service.test.ts +++ b/test/usage-service.test.ts @@ -750,6 +750,18 @@ describe("GoUsageService gateway failures", () => { expect(reading.monthly.percent).toBe(0); }); + it("falls back to Zen overflow when Go usage rejects a 401 on the Zen Google route", async () => { + // `opencode-google` is Zen's Google route, not a Go one: the same 401 that + // falls back on plain `opencode` must fall back here, rather than reaching + // the card as a rejected Go key. + stubFetch(() => new Response('{"type":"error"}', { status: 401 })); + const reading = await serviceWithKey("oc_sk_zen-key").read({ + provider: "opencode-google", + }); + expect(reading.zenOverflow).toBe(true); + expect(reading.quotaUnavailable).toBe(true); + }); + it("rejects an oversized body before parsing it", async () => { // The cap exists because the body is read into memory first; rejecting // after `JSON.parse` would already have paid for the megabyte. diff --git a/test/usage.test.ts b/test/usage.test.ts index fee756f2..be81b861 100644 --- a/test/usage.test.ts +++ b/test/usage.test.ts @@ -419,23 +419,47 @@ describe("OpenCode Go Usage", () => { }); describe("tierForRequest", () => { - it("trusts the key prefix above every configured signal", () => { - // The prefix is intrinsic to the credential, so it survives both a - // rotated key and a route the user renamed. + it("never reads the key's prefix", () => { + // Measured: OpenCode issues `oc_sk_…` for Go credentials, so the prefix + // names no tier. The observed URL decides, and the route after it. expect( tierForRequest( - "https://opencode.ai/zen/v1/messages", - "opencode", - "sk-x" + "https://opencode.ai/zen/go/v1/chat/completions", + "opencode-go" ) ).toBe("go"); + // The signature no longer takes the key, so it cannot be consulted. + expect(tierForRequest.length).toBe(2); + }); + + it("files an oc_sk_ key seen on the Go plane under Go", () => { + // The reproduction: read prefix-first, this key was filed under Zen. The + // meter's Go lookup then missed it, and a Zen request carrying a + // placeholder was handed the Go key. + recordCapturedApiKey( + "oc_sk_GOKEY_fixture", + "opencode-go", + "https://opencode.ai/zen/go/v1/chat/completions" + ); + expect(getCapturedApiKey("opencode-go", "go")).toBe( + "oc_sk_GOKEY_fixture" + ); + expect(getCapturedApiKey(undefined, "zen")).toBeUndefined(); + }); + + it("does not claim a foreign sk- key for the Go account", () => { + // An OpenAI key on a relay a deployment routes through the patch. Read by + // prefix it was filed as Go and offered to every Go request. + recordCapturedApiKey( + "sk-openai-fixture", + "my-relay", + "https://relay.example/v1/chat/completions" + ); expect( - tierForRequest( - "https://opencode.ai/zen/go/v1/chat/completions", - "opencode-go", - "oc_sk_x" - ) - ).toBe("zen"); + tierForRequest("https://relay.example/v1/chat/completions", "my-relay") + ).toBe("unknown"); + // Unknown stays answerable by route, as before, but is not filed by tier. + expect(getCapturedApiKey("my-relay")).toBe("sk-openai-fixture"); }); it("trusts the URL above the provider route id", () => { @@ -531,7 +555,7 @@ describe("OpenCode Go Usage", () => { "unknown" ); expect(tierForRequest()).toBe("unknown"); - expect(tierForRequest("", "", "")).toBe("unknown"); + expect(tierForRequest("", "")).toBe("unknown"); }); }); diff --git a/test/vendor-version.test.ts b/test/vendor-version.test.ts index 6161b38a..e951dd96 100644 --- a/test/vendor-version.test.ts +++ b/test/vendor-version.test.ts @@ -16,7 +16,9 @@ import { describe, expect, it } from "vitest"; import { bareLiteral, disagreements, + GENERATED_TABLES, pinnedVersion, + scansFile, } from "../scripts/vendor-version.ts"; /** @@ -101,3 +103,38 @@ describe("copies that disagree with the constant", () => { expect(disagreements(PINNED, agreeing, agreeing)).toEqual([]); }); }); + +describe("which files the copy scan reads", () => { + it("reads the files a copy can live in", () => { + for (const path of [ + "src/identity.ts", + "src/settings-page.tsx", + "README.md", + "docs/a.md", + "test/fixtures/x.json", + "scripts/x.yaml", + ".github/workflows/ci.yml", + ]) { + expect(scansFile(path), path).toBe(true); + } + }); + + it("skips the generated tables, which hold vendor data rather than copies", () => { + // The premise: both are TypeScript, so the extension alone would read them. + // Only the exclusion keeps a refresh from failing a gate that only the + // repository is supposed to be able to break. + expect([...GENERATED_TABLES].toSorted()).toEqual([ + "src/catalog-data.ts", + "src/go-limits-data.ts", + ]); + for (const path of GENERATED_TABLES) { + expect(path).toMatch(/\.ts$/u); + expect(scansFile(path), path).toBe(false); + } + }); + + it("skips a file no copy can live in", () => { + expect(scansFile("docs/media/hero.svg")).toBe(false); + expect(scansFile("lib/client.js")).toBe(false); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 1ed574b4..97ea0cb1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -170,6 +170,17 @@ export default defineConfig({ env: { NODE_PATH: "", }, + // Stops the run on a Node below `engines.node`, before any file is + // collected. Inherited by the mock and e2e configs, which spread this one. + globalSetup: ["test/support/node-engine.ts"], + // `offline` refuses non-loopback requests, so no case depends on what the + // vendor's catalog says today. `reset-process-state` clears the plugin's + // module-level stores after every test, so a case cannot pass or fail on + // what an earlier case in the same file left. The e2e config drops the first. + setupFiles: [ + "test/support/offline.ts", + "test/support/reset-process-state.ts", + ], include: ["test/**/*.test.ts", "test/**/*.test.tsx"], /** * Coverage over `src/` only. diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index d6b565e2..f2db2fcc 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -19,6 +19,8 @@ export default defineConfig({ ...base.test, // Only the E2E files; the unit `include` from the base config is replaced. include: ["test/e2e/**/*.e2e.ts"], + // The base config's offline guard is exactly what this suite must not have. + setupFiles: ["test/support/reset-process-state.ts"], // These cross the public internet from CI, where 5s is far too tight. hookTimeout: 30_000, testTimeout: 30_000,