diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5f0ba30..cc3ed67a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,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 +90,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 +146,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/.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 c936af63..d1a145a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,10 +83,11 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f **The general form, because this shape recurred twice in one session**: a sweep that resolves a name in the wrong CONTEXT reports a clean tree as broken. Grepping for callers without the defining file makes same-file calls look absent; resolving a doc's relative links from the repository root instead of from the file's own directory makes every link look dead. Both produced a list of confident findings and both were entirely wrong. A third instance is reading a UTC timestamp as local time and concluding that a workflow had not run since the previous evening, when it had run twenty minutes earlier — same shape, wrong context, confident conclusion. Before acting on a sweep, check that the thing being searched is the thing the claim is about, including the clock it is written in. -- `src/go-limits.ts` — the allowance lookup, the label normalization and the money formatting. `allowanceFor` is the tested one and `attachAllowance` in `usage.ts` is what the meter actually calls, and they are the same lookup written twice: the second inlines `GO_MODEL_LIMITS[model] ?? GO_MODEL_LIMITS[model.toLowerCase()]` where the first normalizes. They agree today, which is why this is a duplication and not a bug — and the tested one is the one nothing calls, so the next change to either will land on the untested side. +- `src/go-limits.ts` — the allowance lookup, the label normalization and the money formatting. `allowanceFor` is the one lookup: `attachAllowance` in `usage.ts` calls it rather than inlining a second copy, which it used to. - `src/go-limits-data.ts` — the Go plan allowances, per model and per tier, **generated** from the vendor's own page by `scripts/generate-go-limits.ts`; never hand-edit it, and regenerate with `pnpm run limits:shim -- --write`. The scheduled freshness job is what notices when the page moves, which it did: the docs went under `/v2/docs/console/` and serve HTML now, so the generator reads a table off that page rather than the markdown it used to. +- `scripts/freshness-scope.ts` — which of CI's three freshness checks (`catalog:shim`, `limits:shim`, `cli:version`) a pull request is GATED on: the ones whose inputs (the check's script and the local modules it loads, walked from the source as Node loads it — types erased by Node's own stripper, specifiers read by `es-module-lexer` — plus `src/identity.ts` for the CLI version; never the generated files) its own diff touches. The rest only warn, because vendor drift is not the pull request's regression: for the two generated tables the nightly run regenerates them and proposes the refresh, and for the CLI version it warns and the bump is a human pull request, which then gates. The gates fail closed: each runs unless the scope step answered `report`. The CLI version's offline half — every copy in the tree agrees with the pin, `cli:version -- --copies` — gates every pull request, since it skips the generated tables and only the repository can break it. `test/freshness-scope.test.ts` walks the real generators, runs the scope step's own shell and the CLI it calls, and evaluates the workflow's step conditions. - `src/responses-routes.ts` · `src/responses-provider.ts` — the protocol table and the in-process mount of the host's `llm-pi-ai`. -- `src/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/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. **Not wired yet**: nothing in `src/` imports it, only its own test, so read it as the next step rather than as live behaviour. - `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`. @@ -95,21 +96,16 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f - `src/opencode-paths.ts` — where an OpenCode installation keeps its files, ported from `~/dev/opencode`'s `global-roots.ts` (the directory) and `database-path.ts` (the filename). It takes `home`, `env` and `channel` as parameters and reads neither the environment nor the filesystem, which is what makes the Windows and Linux layouts testable from here. Two facts it encodes that are easy to get wrong: the directory is XDG on EVERY platform — macOS and Windows use `~/.local/share` too, and `os.homedir()` is the only platform-dependent call — and the FILENAME is not constant, being `$OPENCODE_DB`, else `opencode.db` on a stable channel, else `opencode-.db`. The database sits under `data` while the config sits under `config`, so one root cannot serve both. It is the foundation of the opt-in reader: `go-discovery.ts`'s `resolveOpencodeStoreKey` is what imports it, and nothing else does. - `src/opencode-store.ts` — reading the credentials an OpenCode installation already holds, through `node:sqlite`. Two rules shape it: nothing throws (a missing file, a foreign schema and `:memory:` all answer `undefined`), and a value never leaves the return value — no log line, no error message, no client bundle. `node:sqlite` is host-only, which is what keeps the module on the host side of the bundle rule. It is reached only through `resolveOpencodeStoreKey`, which is the last step of every key-source policy and is off unless `readOpencodeStore` is on. Its second reader, `inspectOpencodeStore`, answers the _other_ question — "is there a credential here, and whose is it?" — with a sanitized summary (email, org, kind, expiry) and never the token, which is what lets the settings card show the evidence beside the permission. - `src/opencode-store-contract.ts` · `src/opencode-store-service.ts` — the inspection seam: the sanitized summary type both sides share, and the host service that produces it. The contract is pure data (no `node:`, no context), so the client bundle may import it; the service is the half that touches `node:sqlite` and the filesystem, and it rewrites the store path to `~`-relative before answering — a home directory is a fact about the machine, not about the plugin. `inspect` is declared through the `Object.defineProperty` marker rather than `@Remote`, for the reason `usage.ts` documents. -- `src/console-auth.ts` — the Console device flow and the two reads a login needs: `/api/user` and `/api/orgs`. -- `src/token-store.ts` — a login as one `grant` record: the token pair and its owner, written atomically. -- `src/login-flow.ts` — the flow that keeps what it learned. A login that cannot read its owner FAILS rather than storing a token without its org. It also exports two conveniences nothing calls: `orgForLogin`, which is `login?.orgId`, and `summarizeLogin`, which is the owner and the tokens as one value. They are not missing behaviour the way `login-session.ts` was — the seam's `state()` deliberately carries no tokens, so `summarizeLogin` is for a host-side caller that does not exist yet, and `orgForLogin` is a one-line accessor. Read them as API surface rather than as wiring: safe to delete, and nothing is silently absent while they are here. -- `src/login-credential.ts` — one reader for a request's login credential, shared by the fetch patch and the Google bridge so neither knows how a grant is stored. It answers a token **with its org**, because the two travel together: a request has to carry the org, and a token with the wrong one is worse than no token. Sessions are cached per plane so concurrent callers share a refresh, but every operation re-reads the grant, so one written after a session was built is still seen. The order is the deployment's own — the DSH grant on the requested plane, then the same account through the other plane, then the installation's account when the deployment opted in — and an unknown plane consults nothing. A revoked grant of an existing owner is a `MISSING_CREDENTIAL` failure rather than a fallback, because absence permits one and a revocation does not. -- `src/login-contract.ts` · `src/login-service.ts` — the three calls a settings card makes (`begin`, `poll`, `state`), their codecs, and the host half that answers them. **A credential never crosses this seam**: the state carries an email and an org, and the parser is a whitelist. -- `src/login-session.ts` — the session for one plane: it joins the store and the client and refreshes the token BEFORE handing it out, so `token()` answers one good for the next call and a caller never handles an expired one. **`loginTokenReader` goes through it**, which is the point — reading the store directly, which the reader used to do, hands out whatever is on disk until it expires and then fails. Worth knowing how it was found: it was referenced by nothing but its own test for a while, and the first reading of that was 'dead code'. It was the half that was missing. -- A timing dependency both remotes share, 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.opencodeLogin` / `.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. The meter won that race in the deployment this was written for — its card rendered with `readUsage` present, which is only possible if the injector saw the mounted remote — and the sign-in row did NOT, because its remote was never mounted at all. So the race is real but was not the cause here; the cause was the missing mount, one entry up. Both now report when the lookup comes back empty — `no opencodeLogin service on the remote` with the keys the remote does expose — so the symptom is a line in the console rather than a blank. -- `src/org-cache.ts` — the org a plane's requests carry, in one small module both sides import. `patchFetch` asks for it synchronously and cannot go to the store itself, so the login service writes it when a login completes: a value read once at startup would miss a grant that appeared minutes later, which is exactly what it did. +- 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`), so the symptom is a console line rather than a blank. +- **Sign-in is not in this build.** An earlier line of work carried a Console device-flow login (`console-auth`, `token-store`, `login-*`, `org-cache`, `settings-login`); main carries the API-key path only, and the login is v2 work. 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`. -Web client bundle (`lib/client.js`): `src/settings-page.tsx` (entry, wiring only), the `settings-*` card and its controls, the sign-in row — `src/settings-login.tsx` — 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 check # format, lint, types diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b8ab64d..0f5331c7 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 `. @@ -59,6 +63,10 @@ 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. +- **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,6 +79,24 @@ 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. diff --git a/README.md b/README.md index 31d7f18c..a033a00b 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ The package is `dsh-opencode-patch` under every name it is used by: the dependen // ~/.dsh/profiles/web/package.json { "dependencies": { - "dsh-opencode-patch": "^0.14.0", + "dsh-opencode-patch": "^1.4.1", }, "dsh": { "profile": { @@ -146,9 +146,9 @@ DSH speaks three of the four families itself. The fourth — Google Generative A ### 2. OpenCode Go (`provider: opencode-go`) — subscription quota -- **Google Generative AI** (`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`): `gemini-4-argon-contributor`, served by the internal `opencode-go-google` adapter. Add the model under your existing `opencode-go` provider; the bridge inherits that provider's API key reference (default `OPENCODE_GO_API_KEY`). It supports image attachments and signed tool-call replay. Only configured models are mounted; access still depends on your Go plan. This endpoint is also used with `endpoint: legacy`, because Go has no legacy Google endpoint. +- **Google Generative AI** (`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`): `gemini-4-argon`, served by the internal `opencode-go-google` adapter. Add the model under your existing `opencode-go` provider; the bridge inherits that provider's API key reference (default `OPENCODE_GO_API_KEY`). It supports image attachments and signed tool-call replay. Only configured models are mounted; access still depends on your Go plan. This endpoint is also used with `endpoint: legacy`, because Go has no legacy Google endpoint. -- **OpenAI Chat Completions** (`https://opencode.ai/zen/go/v1/chat/completions`): `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` +- **OpenAI Chat Completions** (`https://opencode.ai/zen/go/v1/chat/completions`): `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `iceman-contributor-free`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` - **OpenAI Responses** (`https://opencode.ai/zen/go/v1/responses`): `gpt-5.6-luna`, `gpt-6-luna`, `grok-4.5`, `grok-4.6`, `grok-4.7`, `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor` - **Anthropic Messages** (`https://opencode.ai/zen/go/v1/messages`): `claude-haiku-5-5`, `minimax-m2.7`, `minimax-m3`, `qwen3.7-plus`, `qwen3.8-flash`, `qwen3.8-max` - Monitored by the live 3-window quota meter (5-hour rolling, weekly, monthly). The full set ships in the bundled catalog — see [Authoritative Model Catalogs](#7-authoritative-model-catalogs-dual-local-shims--real-time-swr-updates). @@ -159,13 +159,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)_ | 25 | 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 **82** active `opencode` models in `models.opencode.ai` as of 2026-10-23 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 82 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. +Counts are the **81** active `opencode` models in `models.opencode.ai` as of 2026-10-27 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 81 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. The patch **keeps those two routes out of both the model picker and _Settings → Models_**. Three properties hold today, and one does not yet: @@ -221,7 +221,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) diff --git a/README.zh-CN.md b/README.zh-CN.md index dde83f11..875c6547 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -90,7 +90,7 @@ npm install dsh-opencode-patch // ~/.dsh/profiles/web/package.json { "dependencies": { - "dsh-opencode-patch": "^0.12.0", + "dsh-opencode-patch": "^1.4.1", }, "dsh": { "profile": { @@ -144,9 +144,9 @@ DSH 能说其中四种,插件就把这四种都路由掉。**Google Generative ### 2. OpenCode Go (`provider: opencode-go`) — 订阅额度 -- **Google Generative AI**(`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`):`gemini-4-argon-contributor`,由内部适配器 `opencode-go-google` 服务。在现有 `opencode-go` 提供商下配置该模型即可,适配器继承该提供商的 API Key 引用(默认 `OPENCODE_GO_API_KEY`),支持图片附件和带签名的工具调用回放。只挂载已配置的模型,可用范围仍由 Go 套餐决定。Go 没有旧版 Google 接口,因此 `endpoint: legacy` 也使用此地址。 +- **Google Generative AI**(`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`):`gemini-4-argon`,由内部适配器 `opencode-go-google` 服务。在现有 `opencode-go` 提供商下配置该模型即可,适配器继承该提供商的 API Key 引用(默认 `OPENCODE_GO_API_KEY`),支持图片附件和带签名的工具调用回放。只挂载已配置的模型,可用范围仍由 Go 套餐决定。Go 没有旧版 Google 接口,因此 `endpoint: legacy` 也使用此地址。 -- **OpenAI Chat Completions**(`https://opencode.ai/zen/go/v1/chat/completions`):`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` +- **OpenAI Chat Completions**(`https://opencode.ai/zen/go/v1/chat/completions`):`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `iceman-contributor-free`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` - **OpenAI Responses**(`https://opencode.ai/zen/go/v1/responses`):`gpt-5.6-luna`, `gpt-6-luna`, `grok-4.5`, `grok-4.6`, `grok-4.7`, `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor` - **Anthropic Messages**(`https://opencode.ai/zen/go/v1/messages`):`claude-haiku-5-5`, `minimax-m2.7`, `minimax-m3`, `qwen3.7-plus`, `qwen3.8-flash`, `qwen3.8-max` - 由实时三窗口额度计量监控(5 小时滚动、每周、每月)。完整集合随内置目录发布——参见[权威模型目录](#7-权威模型目录双本地预置--实时-swr-更新)。 @@ -157,13 +157,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` 的 82 个**在用**模型,统计于 2026-10-23 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 82 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 +数量为 `models.opencode.ai` 中 `opencode` 的 81 个**在用**模型,统计于 2026-10-27 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 81 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 插件把这两条内部路由同时挡在模型选择器和 _Settings → Models_ 之外。**三条性质今天就成立,还有一条尚未成立:** @@ -219,7 +219,7 @@ opencode-responses: 有两点你在使用中会注意到。**冷启动永远不为空**——内置 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) diff --git a/docs/README.md b/docs/README.md index d8e2d6d4..0a0ae411 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ 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 | +| [`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) | 同一份说明的中文版 | 中文 | diff --git a/docs/deep-dive.md b/docs/deep-dive.md index 34c301c9..d6520adb 100644 --- a/docs/deep-dive.md +++ b/docs/deep-dive.md @@ -168,7 +168,7 @@ 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: the three Zen-route Muse Spark 1.2 ids are suppressed, while the paid Go 1.2 contributor entry stays (the CLI still lists it). Startup is instant: no cold-start delay, blocking network calls, or airplane-mode failures. 2. Background revalidation: both catalogs revalidate against [`https://models.opencode.ai/api.json`](https://models.opencode.ai/api.json) — the OpenCode catalog, which today serves the byte-identical object as `models.dev/api.json` — every **60 minutes** (the OpenCode CLI's canonical cycle), merging new models, deprecations and updated limits. Errors degrade gracefully and retain the active catalog. 3. Gateway models-endpoint enrichment: `patchFetch` intercepts `GET …/models` on OpenCode routes and merges the live Go or Zen catalog: human-friendly names (`DeepSeek V4.1 Flash`, `Qwen3.8 Flash`, `Grok 4.7`, `MiMo V2.6 Pro`), verified context windows (up to 1,000,000+ tokens) and max output tokens (up to 384,000), correct input modalities (`text`, `image`), with retired Muse Spark 1.2 rows omitted. 4. Settings “Fetch Available Models” decoration: DSH asks the route's own adapter first, and for an installed `opencode` route `llm-pi-ai` answers from its packaged catalog without calling the gateway. The plugin therefore decorates the hosted discovery result: adapter rows and order are preserved, their metadata enriched from the catalog, provider-retired rows removed, and **models whose protocol DSH cannot speak dropped** (offering one could only fail). It **does not append** catalog rows the adapter did not report: an earlier revision did, and that is how a deployment that had declared four models saw eighty-six in Settings. This is candidate metadata for the settings surface; it never rewrites saved route configuration. Widening a deployment's list is a separate, explicit action — the card's **Check catalog** row, which shows what the catalog offers and declares it only when asked. diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index deeb841c..9147864f 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -94,7 +94,7 @@ So the fix belongs here rather than in anyone's profile: the routes for the othe **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. -`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. 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. diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md new file mode 100644 index 00000000..5a7b629a --- /dev/null +++ b/docs/improvement-plan.md @@ -0,0 +1,610 @@ +# 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 it pins). File references into the host are relative to that repository's `packages/`. + +**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 | Title | State | +| :---- | :-------------------------------------------- | :-------- | +| P0 | suite hardening, 12 defect fixes, this plan | in review | +| P1a | `fix:` shipped breakage | planned | +| P1b | `chore:` hygiene | planned | +| P2 | `feat:` a smaller public API | planned | +| P-T1a | `build:` pin the host | planned | +| P-T1b | `test:` a harness that is the real host | planned | +| P-H | `refactor:` one way to patch a host method | planned | +| P-R1 | `feat:` the plugin owns the plane routes | planned | +| P-R2 | `fix:` the endpoint family, measured | planned | +| P3 | `feat:` Settings complements pi's list | planned | +| P-T2 | `test:` suite structure | planned | +| P-T3 | `ci:` CI and scripts | planned | +| 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. | +| 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 | **Target pi 1.1.0, the version the harness pins.** Installs of the published host resolve `^1.1.0` to the newest 1.x (1.4.0 today), so CI runs both. | +| 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. | +| 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. | +| 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.** Delete `modelOverrides`, `maxContextWindow`, `modelRoutes` and `internalRoutes`, and the README's nonexistent `hiddenModels`. 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 move to the version the published `dsh` resolves (0.2.1-rc.2). It is named once, in `test/support/host-versions.ts`. | + +## 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, not yet fixed (all verified in code) + +| Defect | Where | +| :-- | :-- | +| The deprecated wrapper re-exports a `default` that does not exist, an ESM link error. It is published every release. | `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 | `utils/request-shape.ts:265-271` | +| `enrichModelsResponse` turns a 401/403/5xx into a 200 catalog. It must still fill the Go plane's 404. | `models-catalog.ts:800-804` | +| The warning token doesn't exist, so warnings render as caption text. The host's text token is `--dsw-alias-state-warn-label`. | `settings-card.tsx:159`, `settings-store-inspect.tsx:250` | +| The Settings spinner animates only if the meter is mounted | `settings-usage.tsx:248` → `usage-ui.ts:440` | +| The stream hook writes `debugFile` with `debug` off | `stream-hook.ts:327-340` vs `lifecycle.ts:361` | +| `settings-purge` reports `{ ok: false }` as "not a purge result" and casts instead of using the parser | `settings-purge.ts:47-55` | +| The stream hook's `prepared` handling is dead. The host never reads `options.prepared`. | `stream-hook.ts:44,200-203,258` | +| The partition test misses `debugFile` | `test/settings-page.test.tsx:1223` | +| `cordis.patch.yml`: phantom `showUsagePrice`, wrong `freeModelMarker: ''` text, incomplete field lists | `cordis.patch.yml:23,36,61` | +| `utils/retry.ts` uses `console` and global timers, against the purity gate | `utils/retry.ts:31,55` | +| `tool-fallback`: the Anthropic branch can't be reached, and the lookup is Go-first | `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 | 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 | **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 | **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:** 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:** 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** (D14). +- 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:` shipped breakage.** + - The wrapper's broken `export { default }`. + - `isZenProvider("opencode-google")` answers false. + - `enrichModelsResponse` passes a 401/403/5xx through, and still fills the Go plane's 404. + - The warning colour uses `--dsw-alias-state-warn-label`. + - The spinner keyframes are in the settings styles. + - One debug gate. + - `settings-purge` uses `parsePurgeResult`. + - The endpoint copy (`config.ts`, `config-values.ts`, README) names the per-protocol inference family for `modern`, with `auto` as its alias. +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. +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` gains `overrides: { "@earendil-works/pi-ai": "1.1.0" }` and `patchedDependencies`, with a committed copy of the harness's patch. The lockfile is regenerated. + - The `@deepseek-ai/*` packages move to rc.2, and the devDependencies gain `dsh-llm`, `dsh-app-boot`, `dsh-config-editor`, `dsh-settings` and `cordis-plugin-loader`. + - Add `test/support/host-versions.ts`, with a version test: `loadHostPi().version` is 1.1.0, and the root pi is the same copy. + - Rewrite `mount-protocols.test.ts` as "every catalog SDK maps to a protocol that pi's `api/*` exports implement", keeping the row that pi 1.1.0's Go provider lacks Google. +5. **P-T1b `test:` the real-host harness.** 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. **P5 `refactor:` installers.** `installRemoteService` replaces the three mount-once copies. +13. **P6 `refactor:` the fetch patch's credential fill.** The adapter path is already D20. + - One resolver, for an empty or placeholder wire key only. + - A parity test over the deciders, with the adapter path as its own row: a rotated key takes effect, and a service failure propagates. + - A `CaptureStore` per root, evicting a key on 401. +14. **P7 `refactor:` one source per table.** One `planeForRoute` (four copies today), `formatUsd` and `specFor`; delete the duplicate `ALL_THINKING_LEVELS` and the capitalisation copies. +15. **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. +16. **P9 `refactor:` remote seam.** `defineRemote`, `markRemote` and `callRemote`, with the parity test reading the contracts. +17. **P10a `refactor:` pure module moves.** + - `models-discovery` → `host/*`; + - `go-discovery` → `credentials.ts`; + - split `usage-ui`; + - `fetch-patch` → `request/`. +18. **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. +19. **P11 `refactor:` UI on the primitives.** +20. **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 → 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. + +## Test matrix + +**P1a–P2** + +- **Unit:** + - each shipped breakage, red before its fix; + - the wrapper forwarder; + - 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 pi 1.1.0. + +**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) pi 1.1.0, pinned and patched, 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 0d7ef7d8..8d7ad753 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -8,11 +8,11 @@ OpenCode serves its models on **four different API shapes**, and which one a mod | API shape | Wire endpoint | Which models | Signal in the catalog | | :-- | :-- | :-- | :-- | -| OpenAI **Chat Completions** | `POST /zen/v1/chat/completions` | **the default**: 25 of 82 | the model names **no** SDK | -| OpenAI **Responses** | `POST /zen/v1/responses` | 31 of 82 | `@ai-sdk/openai` | -| Anthropic **Messages** | `POST /zen/v1/messages` | 17 of 82 | `@ai-sdk/anthropic` | -| Google **Generative AI** | `POST /zen/v1/models/:streamGenerateContent` | 8 of 82 | `@ai-sdk/google` | -| Mistral **Conversations** | `POST /zen/v1/chat/completions` | 1 of 82 | `@ai-sdk/mistral` | +| OpenAI **Chat Completions** | `POST /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/redirect-planes.md b/docs/redirect-planes.md index 559471dd..a7ed34cb 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -10,8 +10,8 @@ A **plane** is one gateway base URL with its own catalog and its own credential. | Plane | Route id | Base URL | Credential | Models | | :-- | :-- | :-- | :-- | --: | -| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | `OPENCODE_API_KEY` | 82 served / 127 listed | -| Go (subscription) | `opencode-go` | the `endpoint` family's | `OPENCODE_GO_API_KEY` | 28 served / 32 listed | +| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | `OPENCODE_API_KEY` | 81 served / 127 listed | +| Go (subscription) | `opencode-go` | the `endpoint` family's | `OPENCODE_GO_API_KEY` | 30 served / 34 listed | **There is no longer one base URL per plane to quote.** The gateway answers on two endpoint families and the `endpoint` setting picks one: `auto` follows the catalog, which today names `…/inference/openai/v1` and `…/inference/go/openai/v1`, and `legacy` pins the older `…/zen/v1` and `…/zen/go/v1`. Both are still served and they are entitled separately, which is why the choice is the user's rather than a migration. The dated measurement below was taken on the legacy family. @@ -25,17 +25,17 @@ A **shape** is one wire protocol, named by the SDK a model declares in the catal | SDK in the catalog | Protocol | Zen | Go | | :------------------ | :---------------------- | --: | ----: | -| _(none)_ | `openai-completions` | 25 | 17 | +| _(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 82 on Zen, 0 of 28 on Go. A model that speaks the completions shape simply omits `provider.npm` and inherits it. So "no SDK" does not mean "unknown"; it means _the provider default_, which is the protocol the route was itself registered with. +**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/package.json b/package.json index edb6cf27..d1ff01cf 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,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", @@ -101,6 +102,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", @@ -108,7 +110,8 @@ "typescript": "^7.0.2", "ultracite": "^8.0.1", "vite-plus": "^1.3.0", - "vitest": "5.0.5" + "vitest": "5.0.5", + "yaml": "2.9.1" }, "peerDependencies": { "@deepseek-ai/cordis": ">=4.0.5-alpha.2 <4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35a94d0b..cc02dfad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,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 @@ -236,6 +239,9 @@ importers: vitest: specifier: 5.0.5 version: 5.0.5(@types/node@26.6.5)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.2.0(@types/node@26.6.5)(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 7e30f364..5dffc266 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"; @@ -31,6 +41,17 @@ import { readdir, readFile } from "node:fs/promises"; const ROOTS = ["src", "test", "docs", "scripts", "README.md"]; const EXTENSIONS = /\.(?:ts|tsx|md|json|ya?ml)$/; +/** + * Not 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. + */ +const GENERATED = new Set(["src/catalog-data.ts", "src/go-limits-data.ts"]); + /** The constant every other copy must agree with. */ const CONSTANT = /OPENCODE_CLI_VERSION\s*=\s*"(\d+\.\d+\.\d+)"/; const CONSTANT_FILE = "src/identity.ts"; @@ -58,7 +79,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 (EXTENSIONS.test(path) && !GENERATED.has(path)) { record(path, await readFile(path, "utf8")); } } @@ -93,6 +114,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) { @@ -115,4 +142,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 72f9629c..f1af2485 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -538,8 +538,14 @@ if (releaseYml.includes("scripts/publish-scoped.ts")) { * list and twenty localized strings left with it. A ceiling that stayed at 140 * would have quietly stopped being a tripwire — 12 KB of slack is more than this * guard exists to tolerate. + * + * Raised 128 -> 132 KB 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. */ -const CLIENT_BUDGET = 128 * 1024; +const CLIENT_BUDGET = 132 * 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..9d799662 --- /dev/null +++ b/scripts/freshness-scope.ts @@ -0,0 +1,316 @@ +/** + * 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 the pinned version out of + * `src/identity.ts` with a 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. + * `check-vendor-version.ts` keeps its own copy of this list (it skips these in + * its copy scan, since they hold vendor data, not copies the repository + * maintains) rather than importing it, which would make this module one of its + * inputs; `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 490db224..7803bdbd 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/src/catalog-data.ts b/src/catalog-data.ts index bccbc339..a9285d67 100644 --- a/src/catalog-data.ts +++ b/src/catalog-data.ts @@ -182,14 +182,14 @@ export const OPENCODE_GO_CATALOG: readonly CatalogModelSpec[] = [ { context_window: 1_048_576, cost: { - cache_read: 0.2, - input: 4, - output: 20, + cache_read: 0.1, + input: 2, + output: 10, }, - id: "gemini-4-argon-contributor", + id: "gemini-4-argon", input_modalities: ["text", "image"], max_output_tokens: 65_536, - name: "Gemini 4 Argon Contributor", + name: "Gemini 4 Argon", provider_npm: "@ai-sdk/google", reasoning: true, reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }], @@ -323,6 +323,20 @@ export const OPENCODE_GO_CATALOG: readonly CatalogModelSpec[] = [ reasoning: true, reasoning_options: [{ type: "effort", values: ["none", "high"] }], }, + { + context_window: 262_144, + cost: { + cache_read: 0, + input: 0, + output: 0, + }, + id: "iceman-contributor-free", + input_modalities: ["text"], + is_free: true, + max_output_tokens: 131_072, + name: "Iceman Contributor Free", + reasoning: true, + }, { context_window: 262_144, cost: { @@ -340,6 +354,7 @@ export const OPENCODE_GO_CATALOG: readonly CatalogModelSpec[] = [ context_window: 1_048_576, cost: { cache_read: 0.3, + cache_write: 3, input: 3, output: 15, }, @@ -350,6 +365,21 @@ export const OPENCODE_GO_CATALOG: readonly CatalogModelSpec[] = [ reasoning: true, reasoning_options: [{ type: "effort", values: ["max"] }], }, + { + context_window: 1_048_576, + cost: { + cache_read: 0.2, + cache_write: 2, + input: 2, + output: 10, + }, + id: "kimi-k3.1-pro", + input_modalities: ["text", "image"], + max_output_tokens: 131_072, + name: "Kimi K3.1 Pro", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], + }, { context_window: 1_000_000, cost: { @@ -839,23 +869,6 @@ export const OPENCODE_ZEN_CATALOG: readonly CatalogModelSpec[] = [ { type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }, ], }, - { - context_window: 1_000_000, - cost: { - cache_read: 0.028, - input: 0.14, - output: 0.28, - }, - id: "deepseek-v4-flash", - input_modalities: ["text"], - max_output_tokens: 384_000, - name: "DeepSeek V4 Flash", - reasoning: true, - reasoning_options: [ - { type: "toggle" }, - { type: "effort", values: ["low", "high", "max"] }, - ], - }, { context_window: 1_000_000, cost: { @@ -1670,6 +1683,7 @@ export const OPENCODE_ZEN_CATALOG: readonly CatalogModelSpec[] = [ context_window: 1_048_576, cost: { cache_read: 0.3, + cache_write: 3, input: 3, output: 15, }, diff --git a/src/fetch-patch.ts b/src/fetch-patch.ts index 272d468a..6ecd614b 100644 --- a/src/fetch-patch.ts +++ b/src/fetch-patch.ts @@ -198,6 +198,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); @@ -291,11 +310,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) + !isFreeModelBody(bodyToPatch) ) { warnedNoCredential.add(plane); const sources: string[] = [ @@ -312,7 +329,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" ); } } @@ -404,24 +421,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 4de611ca..06af45fa 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-data.ts b/src/go-limits-data.ts index ba6f7339..0e6fc62f 100644 --- a/src/go-limits-data.ts +++ b/src/go-limits-data.ts @@ -6,7 +6,7 @@ * OpenCode's own Go documentation; `pnpm run limits:shim` reports staleness and * `--write` rewrites this file. * - * 27 models. + * 28 models. */ import type { ModelAllowance } from "./go-limits.ts"; @@ -27,10 +27,10 @@ export const GO_MODEL_LIMITS: Readonly> = { goPlus: 160, name: "DeepSeek V4.1 Flash (Off-Peak)", }, - "gemini-4-argon-contributor": { - go: Number.NaN, - goPlus: Number.NaN, - name: "Gemini 4 Argon Contributor", + "gemini-4-argon": { + go: 30, + goPlus: 120, + name: "Gemini 4 Argon (50% off)", }, "glm-5.2": { go: 60, @@ -82,6 +82,11 @@ export const GO_MODEL_LIMITS: Readonly> = { goPlus: 60, name: "Kimi K3", }, + "kimi-k3.1-pro": { + go: 15, + goPlus: 60, + name: "Kimi K3.1 Pro", + }, "longcat-2.0": { go: 60, goPlus: 240, 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/key-capture.ts b/src/key-capture.ts index c6edb24b..0d9dd0da 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 5579a0a8..5e7214ff 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, @@ -401,31 +401,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?.(); @@ -450,6 +426,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. * @@ -457,73 +454,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. @@ -546,6 +582,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 dae9c5ea..642de652 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 @@ -696,7 +715,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/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-page.tsx b/src/settings-page.tsx index e66c1e76..195baca8 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"; @@ -47,14 +48,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-store.ts b/src/settings-store.ts index c4d1245b..e9df08e9 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -8,6 +8,7 @@ */ import { + parseStoreInspection, STORE_NAMESPACE, type OpencodeStoreInspection, } from "./opencode-store-contract.ts"; @@ -42,18 +43,28 @@ export const inspectStore = async ( } try { const answer: unknown = await call(); + if (isRecord(answer) && answer.ok === false) { + // The remote's own failure, said in its own words rather than as a shape + // error about the envelope it arrived in. + const { error } = answer; + return { + kind: "failed", + message: + isRecord(error) && typeof error.message === "string" + ? error.message + : "the host could not inspect the installation", + }; + } const value = isRecord(answer) && "value" in answer ? answer.value : answer; - if (!isRecord(value) || typeof value.found !== "boolean") { + const inspection: OpencodeStoreInspection | undefined = + parseStoreInspection(value); + if (inspection === undefined) { return { kind: "failed", message: "host returned an invalid inspection shape", }; } - return { - kind: "inspected", - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - inspection: value as unknown as OpencodeStoreInspection, - }; + return { inspection, kind: "inspected" }; } catch (error: unknown) { return { kind: "failed", diff --git a/src/stream-hook.ts b/src/stream-hook.ts index 710f8857..dcc39b4a 100644 --- a/src/stream-hook.ts +++ b/src/stream-hook.ts @@ -349,7 +349,14 @@ export const createStreamHook = ( } const modelProp: unknown = options.model; const modelName = typeof modelProp === "string" ? modelProp : "unknown"; - const spec = findModelSpec(modelName); + // The ROUTE's plane first, as the dispatch lookup above does: the two planes + // price some shared ids differently (deepseek-v4-pro is 0.66/1.98 on Go and + // 1.74/3.48 on Zen), and a Go-first lookup recorded every Zen turn on those + // at the Go rate. The Go-first lookup is the fallback for a model neither + // route's plane carries. + const spec = + findModelSpecOn(catalogPlaneForRoute(providerKey), modelName) ?? + findModelSpec(modelName); const wrappedStream = async function* wrappedStream() { for await (const chunk of downstream) { 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/test/architecture.test.ts b/test/architecture.test.ts index 53c1f050..802dc2db 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -254,3 +254,106 @@ describe("architecture · utils/ holds only pure functions", () => { expect(violations).toEqual([]); }); }); + +describe("architecture · the client bundle reaches nothing host-only", () => { + const SRC = join(import.meta.dirname, "..", "src"); + /** The client bundle's entry, as `vite.config.ts` names it. */ + const ENTRY = "settings-page.tsx"; + + /** + * 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 the entry reaches at runtime, entry included. */ + const clientGraph = (): Map => { + const seen = new Map(); + const visit = (file: string): void => { + if (seen.has(file)) { + return; + } + const source = stripComments(readFileSync(join(SRC, 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; + }; + + /** 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); + }); +}); diff --git a/test/attachment-services.test.ts b/test/attachment-services.test.ts index f1647c72..fa8ecf05 100644 --- a/test/attachment-services.test.ts +++ b/test/attachment-services.test.ts @@ -58,9 +58,7 @@ it("releases optional service scopes when adapter registration fails", () => { host: { PiAiAdapter }, plane: "opencode-go", apiKeyRef: "UNUSED_KEY", - specs: OPENCODE_GO_CATALOG.filter( - (spec) => spec.id === "gemini-4-argon-contributor" - ), + specs: OPENCODE_GO_CATALOG.filter((spec) => spec.id === "gemini-4-argon"), llm: { registerAdapter: () => { throw new Error("registration failed"); @@ -145,9 +143,7 @@ it("streams a durable image through the actual Go Gemini adapter", async () => { host: { PiAiAdapter }, plane: "opencode-go", apiKeyRef: "BRIDGE_IMAGE_KEY", - specs: OPENCODE_GO_CATALOG.filter( - (spec) => spec.id === "gemini-4-argon-contributor" - ), + specs: OPENCODE_GO_CATALOG.filter((spec) => spec.id === "gemini-4-argon"), llm: { registerAdapter: (_routes, value) => { adapter = value as typeof adapter; @@ -165,7 +161,7 @@ it("streams a durable image through the actual Go Gemini adapter", async () => { try { for await (const _chunk of adapter!.stream({ provider: "opencode-go-google", - model: "gemini-4-argon-contributor", + model: "gemini-4-argon", messages: [ { role: "user", diff --git a/test/catalog.test.ts b/test/catalog.test.ts index a95579b3..5505c76d 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -35,7 +35,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"; @@ -91,7 +91,7 @@ describe("OpenCode Model Catalog & Enrichment", () => { }); it("ships only active, priced models in the bundled Go shim", () => { - expect(OPENCODE_GO_CATALOG.length).toBe(28); + expect(OPENCODE_GO_CATALOG.length).toBe(30); for (const model of OPENCODE_GO_CATALOG) { expect(model.id.length).toBeGreaterThan(0); expect(model.name.length).toBeGreaterThan(0); @@ -197,7 +197,7 @@ 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(82); + 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 @@ -371,9 +371,7 @@ describe("OpenCode Model Catalog & Enrichment", () => { data: Array<{ id: string }>; }; // The GO catalog's own rows are in it… - expect(goJson.data.some((m) => m.id === "gemini-4-argon-contributor")).toBe( - true - ); + expect(goJson.data.some((m) => m.id === "gemini-4-argon")).toBe(true); expect(goJson.data.some((m) => m.id === "muse-spark-1.3-contributor")).toBe( true ); @@ -723,6 +721,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, @@ -1144,11 +1165,7 @@ describe("routing · the plane half of the decision", () => { it("leaves a model with no protocol alone", () => { expect( - internalRouteFor( - "opencode-go", - "gemini-4-argon-contributor", - "@ai-sdk/google" - ) + internalRouteFor("opencode-go", "gemini-4-argon", "@ai-sdk/google") ).toBe("opencode-go-google"); expect(internalRouteFor("opencode-go", "grok-4.7")).toBeUndefined(); }); diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index df1e68fb..8b04a079 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -265,6 +265,19 @@ const otherProtocols = (expected: string) => const freeTierClosed = (result: Probe): boolean => result.summary.includes("FreeTierError"); +/** + * Whether the gateway answered that it no longer serves the MODEL at all. + * + * `400 Upstream request failed: Model is unavailable` and `410` are the gateway + * speaking about the model rather than the route: the catalog can still list an + * id the upstream has withdrawn (measured 2026-10-26 on `nemotron-3-ultra-free`, + * answered identically on two runs a minute apart). Such a candidate is skipped + * like any other the gateway left nothing to assert about, never asserted on. + */ +const modelGone = (result: Probe): boolean => + result.status === 410 || + (result.status === 400 && result.summary.includes("Model is unavailable")); + const accessDisabled = (result: Probe): boolean => result.status === 402 || result.summary.includes("Insufficient account funds") || @@ -342,11 +355,21 @@ 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); - if (!accessDisabled(answer) && answer.status !== 0) { + if ( + !accessDisabled(answer) && + !modelGone(answer) && + answer.status !== 0 && + accept(answer) + ) { return model; } } @@ -359,11 +382,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; @@ -838,17 +869,24 @@ describe.skipIf(!LIVE)("live protocol routing", () => { servedProtocolFor(model) ?? "openai-completions", model ); - if (freeTierClosed(response)) { + // A gated, withdrawn or throttled candidate says nothing about whether + // the free tier is reachable, so the walk moves on to the next one. + if ( + freeTierClosed(response) || + modelGone(response) || + accessDisabled(response) + ) { continue; } answered = { model, status: response.status }; break; } if (answered === undefined) { - // 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. + // Reported rather than skipped: the account (or the vendor) closed, + // withdrew or throttled 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, or throttled. The header-restoration case did not run.` ); return; } diff --git a/test/fetch-patch.test.ts b/test/fetch-patch.test.ts index 6bf4e61e..1fe06d7f 100644 --- a/test/fetch-patch.test.ts +++ b/test/fetch-patch.test.ts @@ -18,15 +18,16 @@ import { SESSION_HEADER, clearCapturedApiKeys, getCapturedApiKey, + getLiveGoCatalog, recordCapturedApiKey, openCodeSessionIdFor, patchFetch, resolveConfig, type ActiveTurnState, } from "../src/index.ts"; +import { createOpencodeStore } from "./support/opencode-store-fixture.ts"; import { createCaptureFetch, - createOpencodeStore, headerOf, parseJsonBody, toolNamesOf, @@ -91,6 +92,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 +141,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[][] = []; diff --git a/test/freshness-scope.test.ts b/test/freshness-scope.test.ts new file mode 100644 index 00000000..3ffbb871 --- /dev/null +++ b/test/freshness-scope.test.ts @@ -0,0 +1,929 @@ +/** + * 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"; + +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(); + expect(inputsOf("cli:version", read)).toStrictEqual([ + "scripts/check-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("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 20b33886..35429290 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/go-google-prepare.test.ts b/test/go-google-prepare.test.ts index ff9fd504..10389a22 100644 --- a/test/go-google-prepare.test.ts +++ b/test/go-google-prepare.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveConfig } from "../src/config.ts"; import { decoratePlaneAdapters } from "../src/models-discovery.ts"; -const model = "gemini-4-argon-contributor"; +const model = "gemini-4-argon"; const route = "opencode-go-google"; describe("Go Google preparation", () => { diff --git a/test/go-google.test.ts b/test/go-google.test.ts index edbb292a..4a69e91c 100644 --- a/test/go-google.test.ts +++ b/test/go-google.test.ts @@ -48,7 +48,7 @@ const createCredentialsSeam = () => { }; }; -const model = "gemini-4-argon-contributor"; +const model = "gemini-4-argon"; const specs = OPENCODE_GO_CATALOG.filter((spec) => spec.id === model); describe("Go Gemini native bridge", () => { diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 9e2c4248..448fbae6 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) @@ -1084,3 +1206,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/models-discovery.test.ts b/test/models-discovery.test.ts index 228aff34..dcada068 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: { @@ -553,9 +575,7 @@ describe("models-discovery: I1's second clause, for a model no catalog describes it("offers Go Gemini through its native bridge", () => { // The Go catalog names the Google protocol, served by the Go bridge. - expect(isServableModel("gemini-4-argon-contributor", "opencode-go")).toBe( - true - ); + expect(isServableModel("gemini-4-argon", "opencode-go")).toBe(true); }); it("offers Zen's free tier models normally", () => { @@ -1102,6 +1122,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/remote-contract.test.ts b/test/remote-contract.test.ts index 5d3e9884..7c3eb209 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/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/responses-routes.test.ts b/test/responses-routes.test.ts index cc0f79c7..adc1366c 100644 --- a/test/responses-routes.test.ts +++ b/test/responses-routes.test.ts @@ -112,9 +112,9 @@ describe("responses-routes: the SDK mapping", () => { expect(internalRouteFor("opencode", "gemini-3-pro", GOOGLE_SDK)).toBe( GOOGLE_INTERNAL_ROUTE ); - expect( - internalRouteFor("opencode-go", "gemini-4-argon-contributor", GOOGLE_SDK) - ).toBe("opencode-go-google"); + expect(internalRouteFor("opencode-go", "gemini-4-argon", GOOGLE_SDK)).toBe( + "opencode-go-google" + ); }); it("leaves an SDK with no protocol anywhere on its own route", () => { 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-page.test.tsx b/test/settings-page.test.tsx index 61bf310d..0258994d 100644 --- a/test/settings-page.test.tsx +++ b/test/settings-page.test.tsx @@ -1314,9 +1314,10 @@ describe("settings-page: every remote gets a mount", () => { const services = mounted.flatMap((contribution) => contribution.descriptors.map((d) => d.service) ); - expect(services).toHaveLength(2); + // `opencodeStore` is the inspection row's; it was the one left out. + expect(services).toHaveLength(3); expect(new Set(services)).toEqual( - new Set(["opencodeGoUsage", "opencodeModelPurge"]) + new Set(["opencodeGoUsage", "opencodeModelPurge", "opencodeStore"]) ); expect(mounted).toHaveLength(1); expect(mounted[0]?.package).toBe("dsh-opencode-patch"); 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/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/test-helpers.ts b/test/test-helpers.ts index ca421970..79893bff 100644 --- a/test/test-helpers.ts +++ b/test/test-helpers.ts @@ -6,11 +6,7 @@ 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"; @@ -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/usage-panel.test.tsx b/test/usage-panel.test.tsx index dc2ec501..602b2a72 100644 --- a/test/usage-panel.test.tsx +++ b/test/usage-panel.test.tsx @@ -12,7 +12,6 @@ import assert from "node:assert/strict"; import { MenuSurface } from "@deepseek-ai/dsh-client-ui-primitives"; import { describe, expect, it, vi } from "vitest"; -import { GO_MODEL_LIMITS } from "../src/go-limits-data.ts"; import type { SessionUsageSnapshot } from "../src/session-cost.ts"; import { en as copyEn, zh as copyZh } from "../src/settings-copy.ts"; import type { GoUsage } from "../src/usage-contract.ts"; @@ -52,11 +51,13 @@ const usage = (overrides: Partial = {}): GoUsage => ({ ...overrides, }); -it("renders unknown Gemini allowances before and after the JSON transport", () => { - const model = "gemini-4-argon-contributor"; - const limits = GO_MODEL_LIMITS[model]!; +it("renders unknown allowances before and after the JSON transport", () => { + // The generator writes `Number.NaN` for a model the vendor's page does not + // price. Whether today's table holds such a row is the vendor's choice, so + // the allowance is built here in the shape the generator emits. + const model = "gemini-4-argon"; const raw = usage({ - allowance: { model, go: limits.go, goPlus: limits.goPlus }, + allowance: { model, go: Number.NaN, goPlus: Number.NaN }, }); // JSON turns the catalog's NaN into null across the host/client boundary. // oxlint-disable-next-line unicorn/prefer-structured-clone -- exercise the host's JSON transport 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.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/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,