diff --git a/.freebuff/project-id b/.freebuff/project-id deleted file mode 100644 index b164097c..00000000 --- a/.freebuff/project-id +++ /dev/null @@ -1 +0,0 @@ -a689d3ef-ace3-4ee0-bdef-783c86931947 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d644f098..a6caa0a7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -29,6 +29,14 @@ updates: # The plugin's own dependencies. Grouped so a routine bump does not arrive as # a dozen PRs; the lockfile is the unit that is actually tested. + # + # Every pull request this opens also needs `channels/rc/pnpm-lock.yaml` + # relocked, which Dependabot cannot do: that lockfile is the root one with the + # host packages re-resolved, so it carries every specifier in `package.json` + # and CI's rc cell installs it frozen. Push one commit running + # `node --experimental-strip-types scripts/use-channel.ts rc --relock` onto + # the pull request; `test/harness-channel.test.ts` names that command when + # the two lockfiles disagree. - package-ecosystem: npm directory: / schedule: @@ -56,17 +64,18 @@ updates: - vitest - "@vitest/*" ignore: - # pi-ai follows the line the main dsh installs, not the registry. The bridge - # loads pi from the wrapper's copy, so our pin has to satisfy the range the - # wrapper declares - now `^1.1.0`, where minor releases stay inside the range - # and a major does not. A second pi beside the wrapper's is the failure this - # prevents: two module instances drift apart the moment either side moves, and - # `test/google-bridge.test.ts` asserts the pin satisfies the range, so a major - # here would land red rather than break something quietly. Minors are - # welcome - they stay inside the wrapper's range - so only majors are ignored. + # The harness cluster and pi move only as a channel bump (D22''), never + # as a dependency update: a bump moves a channel record in + # `test/support/host-versions.ts`, that channel's lockfile and, for alpha, + # `package.json`'s exact `@deepseek-ai/*` pins together (CONTRIBUTING's + # release step 3), and pi is the bridge's, at the newest 1.x its range + # resolves, pinned per channel. A pull request from here would move one of + # them, land red on `test/harness-channel.test.ts`, and take its whole + # group with it. The nightly `host-drift` job reports when either channel + # moves, and P-T3 (a later phase) turns that report into the bump. Every + # update type is ignored; `test/harness-channel.test.ts` holds both rules. + - dependency-name: "@deepseek-ai/*" - dependency-name: "@earendil-works/pi-ai" - update-types: - - version-update:semver-major # React stays at 18: the bundles this plugin ships are host-bound, so a # major here is a coordinated move with the host rather than a bump - and # it is not hypothetical: under react-dom 19 the settings suite fails diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5f0ba30..91fa51af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,9 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # No `version:` input: pnpm/action-setup refuses to run when it is given - # both that and a `packageManager` field, and package.json pins 12.6.0 - # exactly. One source of truth, and the action reads it. + # both that and a `packageManager` field, and package.json pins the pnpm + # version exactly, in `packageManager`. One source of truth, and the + # action reads it. - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: @@ -39,13 +40,8 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run check - - run: pnpm run test - # The zen/go compatibility matrix, offline: `test/mock/` boots a Vite - # server that answers the gateway's endpoints in their own wire formats - # and refuses a request whose shape its endpoint does not accept. - # Deterministic and secretless, so unlike the e2e job it gates every - # push and every PR. - - run: pnpm run test:mock + # The unit and mock suites run in the `test` job below, once per harness + # channel; coverage, further down, is the alpha (default) install's. - run: pnpm run build - run: node --experimental-strip-types scripts/check.ts # Coverage as its own step, not folded into `test`: the ratchet only means @@ -60,6 +56,104 @@ jobs: path: coverage retention-days: 7 + # The plugin supports the latest release of BOTH harness channels (D14'', + # D22''): `rc` is `dsh@latest`, `alpha` is `dsh@alpha`. Each channel's + # default install is what an npm user runs, the channel's cluster with pi + # unpatched at the newest version the bridge's range admits, pinned by that + # channel's committed lockfile (the root one for alpha, + # `channels/rc/pnpm-lock.yaml` for rc). `scripts/use-channel.ts` switches + # the install, and `DSH_CHANNEL`/`DSH_PI` tell `test/host-versions.test.ts` + # which record to hold the tree to, so a switch that silently did not happen + # fails there by name. One exception to "what an npm user runs": the switch + # also repins the plugin's own `@deepseek-ai/schemastery` to the channel's + # line, so the rc cell runs the host's schemastery, not the plugin's + # published pin (which is the alpha line). + # + # The floor cells (pi at the range's floor: 1.1.0 on rc, 1.3.0 on alpha, the + # pi the desktop app ships, unpatched) run nightly and on dispatch, not on + # every pull request: they are the oldest pi a supported host can run, which + # moves only when a channel does. + test: + name: test · ${{ matrix.channel }}${{ matrix.pi == 'floor' && ' · pi floor' || '' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + channel: [alpha, rc] + pi: ${{ fromJSON((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '["newest","floor"]' || '["newest"]') }} + env: + DSH_CHANNEL: ${{ matrix.channel }} + DSH_PI: ${{ matrix.pi }} + 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: 26 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: switch to the ${{ matrix.channel }} install + run: node --experimental-strip-types scripts/use-channel.ts "$DSH_CHANNEL" ${{ matrix.pi == 'floor' && '--floor' || '' }} + - run: pnpm run test + # The zen/go compatibility matrix, offline: `test/mock/` boots a Vite + # server that answers the gateway's endpoints in their own wire formats + # and refuses a request whose shape its endpoint does not accept. + # Deterministic and secretless, so unlike the e2e job it gates every + # push and every PR. + - run: pnpm run test:mock + # A switch restores every tracked file it rewrote; anything left over + # would be a switch that leaks into a commit. + - name: the switch left the tree clean + run: git diff --exit-code && test -z "$(git status --porcelain)" + + # The channel records name what each channel installs TODAY, and the harness + # cuts a release without telling this repository. Nightly and on dispatch, + # `scripts/report-host-drift.ts` reads the registry the way npm resolves a + # channel (dsh at its dist-tag; cordis, the loader, schemastery and pi at the + # newest version the bridge's declared ranges admit) and prints one warning + # per field that moved past `test/support/host-versions.ts`. It REPORTS and + # never gates, so it exits 0 whatever it finds: a channel moving is the + # harness's release, not a regression. P-T3 turns the report into a proposed + # bump pull request; the bump follows CONTRIBUTING's release step 3. + host-drift: + name: harness channels · drift report + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 10 + 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: 26 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: node --experimental-strip-types scripts/report-host-drift.ts + + # `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 +161,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 +217,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/.github/workflows/release.yml b/.github/workflows/release.yml index 94d89322..d79d54f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,13 +129,15 @@ jobs: export NODE_AUTH_TOKEN=${{ secrets.GITHUB_TOKEN }} PUBLISH_REGISTRY=https://npm.pkg.github.com node --experimental-strip-types scripts/publish-scoped.ts # The release claim, checked instead of assumed. Without this the job - # reported success while `@viztor/dsh-opencode*` sat four versions behind + # reported success while `@viztor/dsh-opencode-patch` sat four versions behind # the canonical package, because a publisher that was never registered # fails with the same E404 as a transient fault. - name: verify every published target run: | VER=$(node -p "require('./package.json').version") - pending="dsh-opencode-patch@$VER @viztor/dsh-opencode-patch@$VER @viztor/dsh-opencode@$VER" + # One line: scripts/check.ts reads it to prove this list equals what + # publish-scoped.ts publishes (scripts/scoped-targets.ts). + pending="dsh-opencode-patch@$VER @viztor/dsh-opencode-patch@$VER" # Polls, because a successful `npm publish` does not mean the version # is readable yet: npm answers "Your package is being processed and diff --git a/.gitignore b/.gitignore index 8c33bb8f..ecb00ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage/ # Agent workspace: session notes and scratch data, not source. .workbuddy-ai/ +.freebuff/ diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..6f4247a6 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +26 diff --git a/AGENTS.md b/AGENTS.md index 6735f340..70d91a4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,11 @@ A file's name does not decide whether it is public — its content does. Inciden Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: -- `src/index.ts` · `src/identity.ts` · `src/lifecycle.ts` — the public barrel, the identity constants, and the `apply()` composition (one installer per side effect, in load-bearing order). +- `src/index.ts` · `src/identity.ts` · `src/lifecycle.ts` — the public barrel, the identity constants, and the `apply()` composition (one installer per side effect, in load-bearing order). The barrel is the host's contract and nothing more: `apply`, `Config`, `inject`, `name` and the config types. Everything else is internal, and `test/public-api.test.ts` pins the list, so widening it is a reviewed change. +- `test/support/src.ts` — the one door tests use into `src`: each name re-exported from the module that DEFINES it, never through the barrel, so a module move edits one file. `test/architecture.test.ts` enforces both halves — every name is declared in its target, and no test outside the named exemptions (`vi.mock` targets, fresh re-imports after `vi.resetModules()`, the architecture and public-API tests, the jsdom client tests, the `reset-process-state.ts` setup file) imports a `src` path, and no jsdom test loads the door. +- `test/support/host-versions.ts` — the hosts the plugin supports, named once: one record per harness channel (`rc` = `dsh@latest`, `alpha` = `dsh@alpha`), each with its `@deepseek-ai` cluster, cordis, its loader, the bridge's pi RANGE, the newest pi that range resolves (the channel lockfile's) and the range's floor (the harness's own pin, which only the desktop app ships), plus which one the install under test is (`DSH_CHANNEL`, else the marker `scripts/use-channel.ts` leaves, else `alpha`). `test/host-versions.test.ts` checks the installed tree against the SELECTED record; `test/harness-channel.test.ts` checks the declarations and holds each committed lockfile (`pnpm-lock.yaml` for alpha, `channels/rc/pnpm-lock.yaml` for rc) to its record, including that `@deepseek-ai/schemastery` resolves to one copy inside the cluster's range. +- `scripts/report-host-drift.ts` — the nightly harness-drift report (CI's `host-drift` job, schedule and dispatch only): reads the registry the way npm resolves each channel and prints one `::warning::` per record field that moved, exiting 0 whatever it finds, because a channel moving is the harness's release, not a regression. Its rules are `scripts/host-drift.ts` (`hostDrift`, pure), held by `test/host-drift.test.ts` with known answers, a loopback registry and the job's evaluated condition. P-T3 turns the report into a proposed bump pull request. +- `scripts/use-channel.ts` — switches the dev install: `rc` or `alpha` installs that channel's committed lockfile (unpatched pi at the newest version the bridge's range admits, what an npm user runs), and `--floor` the same cluster with pi at the range's floor, never committed. It restores every tracked file it touched, so a switch leaves no diff. Its pure half is `scripts/channel-manifest.ts` (`channelManifest`, `pinFor`, `workspaceWithPi`, and `withoutHostPackages`, the lockfile `rc --relock` starts from), held by `test/use-channel.test.ts`. `rc --relock` belongs in the same commit as ANY change to `package.json`'s dependencies or `pnpm-workspace.yaml`'s catalog or overrides, not only a channel bump: the rc lockfile carries every specifier the root one does and CI installs it frozen, and `test/harness-channel.test.ts` fails, naming the command, when they disagree. Dependabot ignores the cluster and pi, which move only as a channel bump. **This repository carries no pi patch** (D14''): an npm install runs pi unpatched, the harness's patch reaches users only through the desktop app and touches nothing the plugin reads, and a version-keyed patch would break every pi bump. `test/architecture.test.ts` keeps it that way by failing if `src` ever reads a streamed tool-call delta, partial arguments or a thought signature. - `src/config.ts` · `src/config-values.ts` · `src/utils/guards.ts` — the schema and its defaults; dependency-free readers and type guards. The client bundle may import these, and it does — along with the contract and presentation modules (`usage-contract.ts`, `settings-copy.ts`, `settings-fields.ts`, `usage-ui.ts`, `go-limits.ts`, `opencode-store-contract.ts`). The rule is not a list of names: **the client may import a module that reaches nothing host-only** — no cordis context, no credential seam, no node built-in — and the bundle gate is what enforces it, by failing the build rather than by anyone remembering. An earlier version of this line named two files as the only ones allowed, which the tree had already outgrown. ## `src/utils/` — pure by construction, enforced by test @@ -48,7 +52,7 @@ Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: src/utils/ a function of its arguments. No context, no globals, no I/O. src/ effects: read a context, ask a utility, cause something. -**The membership test for `utils/` is a property, not a vibe:** no `node:*`, no `@deepseek-ai/cordis`, no `process.env`, no imports from outside the folder except `guards.ts`. `test/architecture.test.ts` walks the tree and fails the build otherwise — verified by planting a violation of each kind and watching it fail. +**The membership test for `utils/` is a property, not a vibe:** no `node:*`, no `@deepseek-ai/cordis`, no `process.env`, no `fetch`, no reference to `console` or a global timer (`setTimeout`/`setInterval`/`setImmediate` and their `clear*` counterparts, called or passed as a value, bare or through `globalThis.`/`window.`/`global.`/`self.`), no relative import that resolves outside the folder (`guards.ts` lives inside it). A utility that needs a clock or a logger takes it as a parameter, the way `utils/retry.ts` takes its `sleep` and `warn` and leaves the timer to the caller. `test/architecture.test.ts` walks the folder recursively, resolves each import from the importing file's own directory, and fails the build otherwise — verified by planting a violation of each kind and watching it fail. It is the same trick `scripts/check.ts` already uses for the client bundle, at finer grain. @@ -67,12 +71,13 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f ## What moved -| to `utils/` | from | -| :----------------- | :------------------------------------------------ | -| `request-shape.ts` | new (extracted in #74) | -| `guards.ts` | `src/` | -| `session-id.ts` | the rules inside `session.ts` | +| to `utils/` | from | +| :-- | :-- | +| `request-shape.ts` | new (extracted in #74) | +| `guards.ts` | `src/` | +| `session-id.ts` | the rules inside `session.ts` | | `google-replay.ts` | `src/` (pure translation, so the gate polices it) | +| `retry.ts` | new (7b1c5d4): the settings card's usage-read retry loop; its default `sleep` reached a global timer and its warning reached `console` until P1b moved both to the caller (`backoffOver(timer)`, `warn`) | - `src/session.ts` · `src/turn-store.ts` — the `ses_<12hex><14base62>` derivation and the `AsyncLocalStorage` turn store. - `src/stream-hook.ts` · `src/fetch-patch.ts` · `src/tool-fallback.ts` — the `llm/stream` hook, the fetch interceptor, and the free-tier `read`/`bash` fallback. @@ -85,8 +90,8 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f - `src/go-limits.ts` — the allowance lookup, the label normalization and the money formatting. **One lookup, and it is the tested one**: `attachAllowance` in `usage.ts` used to inline the same expression, and the copy nothing called was the tested one; it now goes through `allowanceFor`. The row's label is the model the allowance belongs to — the picked one, by the catalog's spelling — because a figure beside another model's name states something false about the money. - `src/go-limits-data.ts` — the Go plan allowances, per model and per tier, **generated** from the vendor's own page by `scripts/generate-go-limits.ts`; never hand-edit it, and regenerate with `pnpm run limits:shim -- --write`. The scheduled freshness job is what notices when the page moves, which it did: the docs went under `/v2/docs/console/` and serve HTML now, so the generator reads a table off that page rather than the markdown it used to. +- `scripts/freshness-scope.ts` — which of CI's three freshness checks (`catalog:shim`, `limits:shim`, `cli:version`) a pull request is GATED on: the ones whose inputs (the check's script and the local modules it loads, walked from the source as Node loads it — types erased by Node's own stripper, specifiers read by `es-module-lexer` — plus `src/identity.ts` for the CLI version; never the generated files) its own diff touches. The rest only warn, because vendor drift is not the pull request's regression: for the two generated tables the nightly run regenerates them and proposes the refresh, and for the CLI version it warns and the bump is a human pull request, which then gates. The gates fail closed: each runs unless the scope step answered `report`. The CLI version's offline half — every copy in the tree agrees with the pin, `cli:version -- --copies` — gates every pull request, since it skips the generated tables and only the repository can break it. `test/freshness-scope.test.ts` walks the real generators, runs the scope step's own shell and the CLI it calls, and evaluates the workflow's step conditions. - `src/responses-routes.ts` · `src/responses-provider.ts` — the protocol table and the in-process mount of the host's `llm-pi-ai`. -- `src/adapter-replacement.ts` — taking a provider route over from the adapter that already serves it: `llm.registerAdapter` refuses a route the host owns (`DUPLICATE_ADAPTER`), so the current entry is released first, our adapter is registered through the ordinary API, and the original is put back on disposal. The step towards serving a mixed-protocol route as the same provider the user configured, instead of mounting hidden ones. - `src/purge-service.ts` · `src/purge-contract.ts` — the card's "purge deprecated models" action, and the shape it answers with. It **rewrites the profile**, deleting the declared model entries the live catalog has retired, through `configEditor` (the same service the settings UI writes with). That is deliberate: a read-time filter would leave the declaration in place, and then the only way to explain a missing row is to find the filter. One source of truth, and it is the configuration. - `src/session-cost.ts` · `src/cordis-context.ts` · `src/debug.ts` — per-turn accounting, the typed host interfaces, and stream debug logging. - `src/pi-provider.ts` — the per-protocol base URLs, the pi model object, and the gateway headers. Every value here is measured; see `docs/redirect-planes.md`. @@ -96,18 +101,19 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f - `src/opencode-store.ts` — reading the credentials an OpenCode installation already holds, through `node:sqlite`. Two rules shape it: nothing throws (a missing file, a foreign schema and `:memory:` all answer `undefined`), and a value never leaves the return value — no log line, no error message, no client bundle. `node:sqlite` is host-only, which is what keeps the module on the host side of the bundle rule. It is reached only through `resolveOpencodeStoreKey`, which is the last step of every key-source policy and is off unless `readOpencodeStore` is on. Its second reader, `inspectOpencodeStore`, answers the _other_ question — "is there a credential here, and whose is it?" — with a sanitized summary (email, org, kind, expiry) and never the token, which is what lets the settings card show the evidence beside the permission. - **What the plugin page shows is `docs/detail.md`, not the README.** The page renders one document: the Markdown `meta.detail` in `locale/.json` names, else the whole `README.md`. This package declared nothing for years, so its page carried 461 lines of engineering notes above the settings card. It now declares a page per language that is **one image and nothing else** — a UI preview of the card and the meter (`docs/media/preview.svg`, `.zh.svg`) — which is the shape the upstream plugins use, because the header above the card already carries the title and the one-sentence description. The image is served from the package, so `files` has to ship it (the detail-image route reads package-relative paths). `test/plugin-detail.test.ts` holds the contract: a declared detail in every locale, a path that exists AND is shipped, an image that does the same, and a page that is the picture and nothing else. - `src/opencode-store-contract.ts` · `src/opencode-store-service.ts` — the inspection seam: the sanitized summary type both sides share, and the host service that produces it. The contract is pure data (no `node:`, no context), so the client bundle may import it; the service is the half that touches `node:sqlite` and the filesystem, and it rewrites the store path to `~`-relative before answering — a home directory is a fact about the machine, not about the plugin. `inspect` is declared through the `Object.defineProperty` marker rather than `@Remote`, for the reason `usage.ts` documents. -- **The login stack is not in this tree.** It lives on the `v2` bookmark, and the map used to describe it here for years after it left — nine modules a reader could not find. What this tree carries is the API-key path only; a login grant is the other half, and reviving it is a days-scale port rather than a rebase (`v2` is 163 commits behind, and its `src/index.ts` exports a module the tree no longer has). +- **The login stack is not in this tree.** It lives on the `v2` bookmark, and the map used to describe it here for years after it left — nine modules a reader could not find. What this tree carries is the API-key path only; a login grant is the other half, and reviving it is a days-scale port rather than a rebase (`v2` is 163 commits behind, and its `src/index.ts` exports a module the tree no longer has). The settings copy still holds its strings, and `credentialOrg` is the one remnant with behaviour: a configured org is sent as `x-opencode-org-id`. -- **The meter's remote is a timing dependency worth knowing before debugging a row that does not appear.** The mount is an async `$mount` started in an effect, and the card reads `ctx.remote.opencodeGoUsage` SYNCHRONOUSLY when its injector builds the props. If the injector runs before the mount resolves, the service is not there yet and the surface renders nothing. Both halves now report when the lookup comes back empty — `no opencodeGoUsage service on the remote` with the keys the remote does expose — so the symptom is a line in the console rather than a blank. +- A timing dependency every remote shares, worth knowing before debugging a row that will not appear: the mount is an async `$mount` started in an effect, and the card reads `ctx.remote.` SYNCHRONOUSLY when its injector builds the props. If the injector runs before the mount resolves, the service is not there yet and the surface renders nothing. More often the cause is a namespace with no client descriptor at all — the purge row and the store-inspection row each shipped that way — which `test/remote-contract.test.ts` now catches per method. Each caller reports an empty lookup (`no opencodeStore service on the remote`, `no opencodeGoUsage service on the remote`, with the keys the remote does expose), so the symptom is a console line rather than a blank. -Web client bundle (`lib/client.js`): `src/settings-page.tsx` (entry, wiring only), the `settings-*` card and its controls, and the meter — `src/usage-pill.tsx` (state), `src/usage-panel.tsx` (pure views), `src/usage-ui.ts` (logic and stylesheet). +Web client bundle (`lib/client.js`): `src/settings-page.tsx` (entry, wiring only), the `settings-*` card and its controls — including the store-inspection row, `src/settings-store-inspect.tsx` — and the meter — `src/usage-pill.tsx` (state), `src/usage-panel.tsx` (pure views), `src/usage-ui.ts` (logic and stylesheet). `test/architecture.test.ts` walks this graph from the entry and fails on a node built-in, the cordis runtime, pi, or `process.env`. ## Commands ```sh -vp test run # unit suite +vp test run # unit suite (refuses to start below engines.node) +pnpm run test:shuffle # the unit suite in random order; CI runs it on Node 24 pnpm run test:mock # gateway mock suite: offline, no key, runs in CI -vp test run --coverage # with the coverage ratchet (95/90/93/95) +vp test run --coverage # with the coverage ratchet (thresholds in vite.config.ts) vp check # format, lint, types vp fmt --write # rewrite formatting pnpm run build # build lib/ (host bundle + client bundle) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b8ab64d..622f70ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,8 @@ ## 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. +- The plugin supports the latest release of **both** harness channels, `rc` (`dsh@latest`) and `alpha` (`dsh@alpha`). Their hosts (the `@deepseek-ai` cluster, cordis, its loader, and the pi range the bridge declares) are named once, in `test/support/host-versions.ts`. `pnpm install` installs the alpha channel from the root lockfile; `node --experimental-strip-types scripts/use-channel.ts rc` switches to the rc channel's committed lockfile (`channels/rc/pnpm-lock.yaml`), `… alpha` switches back, and `--floor` puts pi at the range's floor (the harness's own pin) for a local run. Each install runs pi **unpatched**, as an npm user does: this repository carries no pi patch. A switch restores every file it touched, so `git status` stays clean, and the suite reads which channel is installed from the marker the switch leaves (`DSH_CHANNEL` overrides it). `scripts/use-channel.ts rc --relock` regenerates the rc lockfile from the root one, and it runs in the same commit as **any** change to `package.json`'s dependencies or to `pnpm-workspace.yaml`'s catalog or overrides, not only a channel bump: the rc lockfile carries every specifier the root one does, CI installs it frozen, and `test/harness-channel.test.ts` fails, naming the command, when the two disagree (a Dependabot pull request included, which cannot relock it). - A checkout of this repo; for live testing, a DeepSeek Harness profile (see README for the `link:` setup) ## Commands @@ -14,9 +15,13 @@ pnpm install # install dependencies pnpm run check # format + lint + types; must be zero *errors* (warnings are reported, not gating) pnpm run test # Vitest suite, must be fully green and deterministic pnpm run test:coverage # the same run, with the coverage ratchet enforced +pnpm run test:shuffle # the same run in a random order; reproduce a failure with --sequence.seed= +pnpm run test:mock # gateway mock suite: offline, loopback only, runs in CI pnpm run build # vp pack + client rename -> lib/index.mjs, lib/index.d.mts, lib/client.js pnpm run test:e2e # opt-in end-to-end suite; talks to the real OpenCode gateway -pnpm run catalog:shim # regenerate src/catalog-data.ts from models.dev +pnpm run catalog:shim # is src/catalog-data.ts current? (-- --write regenerates it from models.dev) +pnpm run limits:shim # is src/go-limits-data.ts current? (-- --write regenerates it) +pnpm run cli:version # is the pinned OpenCode CLI version the published one? ``` > Note: in some shells `pnpm exec` stalls; invoke the binary directly if so: `node node_modules/.pnpm/vite-plus@*/node_modules/vite-plus/bin/vp `. @@ -34,10 +39,13 @@ OPENCODE_E2E=1 OPENCODE_API_KEY=… OPENCODE_GO_API_KEY=… pnpm run test:e2e - The **free-tier and unmetered** routing cases run keyless on purpose: measured 2026-10-06, a paid model answers `401 AuthError` on every endpoint without a credential, so keyless probing cannot tell them apart at all. Keyless discrimination is real only for the free classes. - `OPENCODE_ZEN_BASE_URL` / `OPENCODE_GO_BASE_URL` retarget the suite at a mirror. -Two files, two jobs: +The e2e files, one job each: - `opencode-live.e2e.ts` — the only place the plugin meets the real API. It catches what a stub cannot: **the vendor changing the payload**. The meter parses `/zen/go/v1/usage` and the picker consumes an enriched `/models` listing, so both shapes are asserted against the live service. The unkeyed cases assert reachability (never a 404) and that our header set does not change the gateway's verdict on a request — compared against the same call without it, rather than hardcoding a status the vendor may tighten. - `patched-fetch-headers.e2e.ts` — a local `node:http` listener, so the _outgoing_ header set crosses a real socket. The live gateway cannot echo a request back; everywhere else in the suite the injected headers are asserted against a captured `fetch`. +- `protocol-routing.e2e.ts` — one turn per protocol against the live gateway: the vendor-side claim that a model's `provider_npm` names its wire format, which a unit test can only read back from our own table. +- `refusal-classification.e2e.ts` — the host's error classifier fed the gateway's measured refusals over a real `node:http` socket, chunked bodies included, after asserting the installed bundle still carries the branches the verdicts rely on. +- `dispatch-levels.e2e.ts` — no network: the installed pi's own level rule against both decorators across the shipped catalog, so every reasoning level the picker offers is one dispatch accepts. ## Live profile loop @@ -45,12 +53,12 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Host (`lib/index.mjs`) — restart required.** The base bundle ships `hmr root: []` (config watches only, `**/node_modules` ignored) and the loader caches ESM imports per process, so a linked package is never re-imported after boot. After every `pnpm run build`, restart `dsh web` — otherwise the profile silently keeps running the module from boot time. Symptom of a stale host: the plugin row is active but the settings card shows "This plugin is not loaded, so it cannot be configured." — `dsh-settings` is filtering on the old module's `Config` schema. - **Client (`lib/client.js`) — refresh suffices.** The client bundle is re-served from disk on every page load; a browser refresh picks it up. -- **Verify after a restart:** the Plugins page shows the **OpenCode Patch** card, and its configure view renders all 17 fields (no unavailable line). `node --experimental-strip-types scripts/check.ts` catches a stale `lib/` before you do (`src/` newer than `lib/`). +- **Verify after a restart:** the Plugins page shows the **OpenCode Patch** card, and its configure view renders every card field (`CARD_FIELDS` in `src/settings-fields.ts`; the `CONFIG_ONLY_FIELDS` knobs stay off the card by design) and no unavailable line. `node --experimental-strip-types scripts/check.ts` catches a stale `lib/` before you do (`src/` newer than `lib/`). ## Code conventions - **100% strict TypeScript.** No `any` leaks, no `as` assertions in `src/` — narrow `unknown` with `in`-operator type guards (`isRecord`, `isUnknownArray`, …). -- **Zero errors and zero warnings.** `vp check` must report neither — that is what `release:gate` and CI enforce, and the warnings tier has been paid down to zero: do not reintroduce one. The `error` tier is reserved for defects: async safety and throw contracts. If a rule fights a correct pattern (e.g. sync Promise wrappers that preserve `AsyncLocalStorage` context), prefer a targeted `oxlint-disable` comment with justification over weakening the rule globally. +- **Zero errors; warnings are reported, not gating** (the `pnpm run check` line above). `release:gate` and CI fail on an error; warnings become gating in phase P12 of `docs/improvement-plan.md`, so do not add one. The `error` tier is reserved for defects: async safety and throw contracts. If a rule fights a correct pattern (e.g. sync Promise wrappers that preserve `AsyncLocalStorage` context), prefer a targeted `oxlint-disable` comment with justification over weakening the rule globally. - **Sync-over-async for context propagation.** `withStore` iterators and the `fetch` patch intentionally return promises from non-`async` functions so `als.run()` keeps turn context without an extra tick. Don't "fix" these into `async`. - **Style:** arrow-function consts (not `function` declarations), dot notation, explicit `=== undefined` checks, `oxfmt` formatting. @@ -59,6 +67,11 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Deterministic only.** No `Math.random()`, no fixed `sleep()` waits. Async file assertions poll with a deadline (`waitForFileContent`). - **No secret fixtures.** Session IDs are derived at runtime (`openCodeSessionIdFor`) or read from `OPENCODE_SESSION_ID`; never hardcode `ses_…` or API keys. `lib/` and `*.log` stay gitignored. - **Restore globals.** Tests that touch `globalThis.fetch` or `process.env` must restore them in `afterEach` (`vi.unstubAllGlobals()`, `delete process.env.…`). +- **Offline by construction.** `test/support/offline.ts` refuses every non-loopback request in the unit and mock suites. The catalog's background revalidation used to reach `models.opencode.ai` dozens of times per run, so a networked runner's results could depend on the vendor's live catalog. A case that means to fetch stubs `fetch` itself. +- **No order dependence.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test. A new module-level store gets a reset next to its siblings. `pnpm run test:shuffle` is how to check, and CI runs it. +- **Tests reach `src` through `test/support/src.ts`.** It re-exports, from each defining module, everything a test needs, so a module move or a barrel change edits one file instead of thirty. A new test imports from `./support/src.ts` (`../support/src.ts` under `test/mock` and `test/e2e`); add a missing name there rather than importing a `src` path. The exceptions are `vi.mock("../src/x.ts")` targets (and their `importOriginal` type), which must name the module; fresh-module re-imports after `vi.resetModules()`, whose subject is module identity; the architecture and public-API tests, whose subject is the `src` layout and the barrel; the jsdom (client) tests, which import the defining module directly, because `src.ts` re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and the next rule keeps that out of client tests; and the setup file `test/support/reset-process-state.ts`, which runs before the jsdom tests too. `test/architecture.test.ts` fails on a static or side-effect `src` import anywhere else, on a dynamic `import("../src/…")` in a file that never calls `vi.resetModules()`, and on a jsdom test that value-imports the door. Importing the door loads the whole `src` graph, host and client, so a module that fails to load fails every test that comes through 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,11 +84,29 @@ The web profile wires this checkout with `link:`, so builds are picked up like t `src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. `responses-provider.ts` and `responses-routes.ts` carry their own per-file floors: the mount has four host contracts to survive, and a well-covered average must not be able to hide it. +## The freshness checks in CI + +The `catalog freshness` job runs three checks against what the vendor publishes: `catalog:shim` (the bundled catalog, `src/catalog-data.ts`), `limits:shim` (the Go allowances, `src/go-limits-data.ts`) and `cli:version` (the pinned CLI version in `src/identity.ts`). The vendor moves all three most days, so what a run does with a mismatch depends on whether the change under test could have caused it. + +- **A pull request is gated on a check only when its own diff touches that check's inputs.** The inputs are the check's script and every local module it loads, transitively, plus `src/identity.ts` for `cli:version`, which the script reads as data. The edges are the ones Node loads: the source's types are erased by Node's own stripper and the specifiers read by a real lexer, so a whole-statement `import type` loads nothing and is no edge, and an import written in a string or template is not one either; anything else is, so the set reaches further than the generator's own folder: the catalog parser loads `src/config.ts`, so a settings change gates `catalog:shim`. `scripts/freshness-scope.ts` derives the inputs from the sources and answers `gate` or `report` for the diff (`git diff --name-only HEAD^1 HEAD` on the merge ref). Change `src/models-catalog.ts` and forget to regenerate, and the pull request fails; that omission is what the gate exists to catch. +- **One half of `cli:version` gates every pull request.** The script checks two things: that every copy of the CLI version in `src/`, `test/`, `docs/`, `scripts/` and `README.md` agrees with `src/identity.ts`, and that the pin is the published version. The first is offline and skips the two generated tables (they hold the vendor's data, not copies the repository maintains), so only the repository can break it, and `pnpm run cli:version -- --copies` runs on every pull request as a gate. Only the registry comparison is scoped as above, and when it only warns, the warning quotes the script's own `error:` line. +- **Otherwise the check reports.** A stale file is a `::warning::`, the job stays green, and the nightly run regenerates both files and opens the refresh as a pull request of its own. A stale CLI pin is only warned about, by every run including the nightly; bumping it is a human pull request to `src/identity.ts` and its copies, which is then gated, because the pin is an input. Vendor drift is not a regression your pull request introduced, so it is not a reason to fail it. +- **The generated files are never inputs.** A pull request that carries only a refresh reports. One that changes `src/catalog-data.ts` or `src/go-limits-data.ts` without touching what generates it gets a warning naming the file, because those files are never edited by hand. +- **A push to `main` reports.** The nightly run regenerates the two generated files and opens the refresh as a pull request; for the CLI version it warns. A manual dispatch only reports the CLI version. None of these paths gates, for the same reason. +- **The gates fail closed.** Each runs unless the scope step answered `report`, so a lost answer gates every pull request rather than skipping the check. + +To see the answer for your branch before CI does: + +```sh +git diff --name-only main... \ + | node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/freshness-scope.ts catalog:shim +``` + ## Release process (maintainers) 1. Use conventional commits (`feat:`, `fix:`) — release-please opens the version-bump + changelog PR automatically; merging it tags and creates the GitHub Release, which fires OIDC publishing to npm and GitHub Packages. -2. For manual releases: bump `version` in `package.json`, add a `CHANGELOG.md` entry, commit, `git tag vX.Y.Z && git push origin vX.Y.Z`. -3. On every release, refresh the **Last verified** date and matrix in `README.md` after smoke-testing: registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds. +2. Avoid manual releases: release-please anchors on the latest GitHub **Release**, not the latest tag, so a bare tag is invisible to it (`AGENTS.md`, "Releasing"). If one is unavoidable, bump `version` in `package.json`, commit, tag, push the tag, and create the GitHub Release in the same step (`gh release create vX.Y.Z --generate-notes`). Never touch `CHANGELOG.md`: release-please owns it. +3. On every release, after smoke-testing (registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds), check the **Supported: the latest release of both DeepSeek Harness channels** line and the **Support policy** paragraph under "Compatibility & Verification" in both READMEs. Their "today" versions, pi ranges and floors, and the default install named a paragraph below, are copied from the channel records in `test/support/host-versions.ts`, and `test/harness-channel.test.ts` holds them to the records. A channel bump **inside the prerelease tuples the peer ranges already admit** (`0.2.2-alpha.2` to `0.2.2-alpha.3`, cordis `4.0.6-alpha.1` to `4.0.6-alpha.2`) is version data only, never `src` or test logic, and this is its whole set: for `rc`, the rc record and `channels/rc/pnpm-lock.yaml` (`scripts/use-channel.ts rc --relock`); for `alpha`, the alpha record, `package.json`'s exact `@deepseek-ai/*` pins and the root lockfile (`pnpm install`); for either, those README sentences in both languages, and `minimumReleaseAgeExclude` lines in `pnpm-workspace.yaml` for the exact versions when the cut is younger than pnpm's one-day release-age window. A pi move is the same data: `pnpm install` and `--relock` keep the pi a lockfile already satisfies, so move it with `pnpm update @earendil-works/pi-ai --no-save` (the root lockfile; `package.json` keeps the union range), then `scripts/use-channel.ts rc --relock`, which carries the root's pi into the rc lockfile, and set each record's `pi`. The same update is what leaves one pi when a new bridge raises its floor above the locked one. The F10 characterization (`test/mount-protocols.test.ts`) is keyed by pi line, so a pi inside a line needs no test change; a pi outside every line is a new major, and a reviewed change. A cut that **opens a new prerelease tuple** (dsh `0.2.3-*`, cordis `4.0.7-*`) is not data: semver admits a prerelease only through a comparator in its own tuple, so it is a reviewed range change that also adds the matching `>=X.Y.Z-0 =0.2.1-alpha.1 <0.2.2 +>=0.2.1-alpha.1 <0.3.0 || >=0.2.2-0 <0.3.0 ``` -One range covers the whole line on both channels: its lower bound (`alpha.1`) sorts below every `rc` of the same line, so rc needs no separate entry. A plain `<0.2.2` would **not** work — semver excludes prereleases unless a comparator in the same `major.minor.patch` tuple carries one, which is how a range written against one channel silently excludes the other. +The first clause covers the `0.2.1` line on both channels: its lower bound (`alpha.1`) sorts below every `rc` of the same line, so rc needs no separate entry. The second is **not** redundant — semver excludes prereleases unless a comparator in the same `major.minor.patch` tuple carries one, so `<0.3.0` alone would silently drop every `0.2.2-*` build, alpha and rc alike. Both clauses stop at `0.3.0`, which leaves the next line a separate decision rather than a silent widening. | Surface | Target | | :-- | :-- | -| **Harness line** | **`0.2.1`**, `alpha` and `rc` channels alike (peer: `>=0.2.1-alpha.1 <0.2.2`) | +| **Harness line** | **`0.2.1`** and the **`0.2.2`** prereleases, `alpha` and `rc` channels alike (peer: the range above) | | **Plugin package** | [`dsh-opencode-patch`](https://www.npmjs.com/package/dsh-opencode-patch) | | **Host profile** | DSH Web profile (`patchReload: live`) | | **Routes claimed** | `opencode`, `opencode-go`, `opencode-responses`, `opencode-anthropic` | | **Gateways** | `opencode.ai/zen/v1` (`/responses`, `/chat/completions`, `/messages`, `:streamGenerateContent`), `zen/go/v1` (`/chat/completions`) | | **Catalog** | Generated from `models.opencode.ai/api.json`; retirement follows the registry's own `status: "deprecated"` flag, with no hand-maintained id list | -| **Upstream pi** | **Not bundled.** The bridge loads whatever `@earendil-works/pi-ai` the harness installed, so a harness bump never skews a pinned copy | -| **Verification gate** | `vp check` clean, **961** deterministic tests green (unit + offline gateway mock), full schema validation, consumer install + load ([`scripts/check.ts`](./scripts/check.ts)) | +| **Upstream pi** | **Not bundled, not patched.** The bridge loads whatever `@earendil-works/pi-ai` the harness installed, so a harness bump never skews a pinned copy; the suite runs each channel's pi as npm resolves it, and its floor | +| **Verification gate** | `vp check` clean, the deterministic unit and offline gateway-mock suites green, full schema validation, consumer install + load ([`scripts/check.ts`](./scripts/check.ts)) | --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 34224590..f613538b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,7 +37,7 @@ **默认状态。** 把 DSH 直接指向 OpenCode、不装任何插件,模型是**完全不能用**的——不是某一种形状不能用,而是全部: -- **请求不会被识别为"编码 agent"发出的。** DSH 发的是自己的 `User-Agent`,不带 `x-opencode-client`、`x-opencode-project`,也不带 `x-opencode-session`;网关看到的就是一次匿名 SDK 调用:免费层模型返回 `403 FreeTierError`,流量还要受厂商对"不表明身份的客户端"所设的滥用规则约束。 +- **请求不会被识别为"编码 agent"发出的。** DSH 发的是自己的 `User-Agent`,不带 `x-opencode-client`、`x-opencode-project`,也不带 `x-opencode-session`;网关看到的就是一次匿名 SDK 调用,流量要受厂商对"不表明身份的客户端"所设的滥用规则约束。**免费层是另一回事,原因也不在这里**:把这些请求头全部恢复、换上 CLI 的 `User-Agent`、带上有效的会话 id 以及回退注入的 `read`/`bash` 工具定义之后,在 OpenCode 之外调用 Zen 的 `-free` 模型仍然返回 `403 FreeTierError`,而同一个请求换成付费模型返回 `200`。这些 id 依然会列出——拒绝本身会说明原因,而把厂商可能仍在服务的模型藏起来是更糟的失败——补丁恢复的请求头服务于付费路由以及厂商声明的客户端规则。 - **没有会话 id 的一轮会被拒、或失去关联。** 每一轮都必须带一个稳定的 `ses_…` 形状的 id,而 DSH 在子代理、后台评估以及 **Auto Review** 这类模式下会完全省略 `sessionId`。 - **免费层的 `/responses` 请求体缺少 `read` 与 `bash` 会被拒**,而 DSH 是刻意不发这两个工具的。 - **目录很单薄。** 裸 `/models` 列表只有 id,几乎没有别的信息,所以选择器里是一堆没有名字、没有价格、没有上下文窗口的行。 @@ -84,7 +84,7 @@ cd ~/.dsh/profiles/web npm install dsh-opencode-patch ``` -包名在所有用到它的地方都是 `dsh-opencode-patch`:你安装的依赖、profile 里的 bundle 条目,以及宿主解析的那一行。 +包名在所有用到它的地方都是 `dsh-opencode-patch`:你安装的依赖、profile 里的 bundle 条目,以及宿主解析的那一行。它也以 [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch) 发布;旧名 `@viztor/dsh-opencode` 已不再发布,仍在使用它的安装请改用上述任一名称。 **2. 启用 bundle**——把该包加入 profile 的 `dsh.profile.bundles` 数组: @@ -92,7 +92,7 @@ npm install dsh-opencode-patch // ~/.dsh/profiles/web/package.json { "dependencies": { - "dsh-opencode-patch": "^0.12.0", + "dsh-opencode-patch": "^1.8.0", }, "dsh": { "profile": { @@ -115,7 +115,7 @@ npm install dsh-opencode-patch | 没有补丁时 | 使用 `dsh-opencode-patch` 后 | | :-- | :-- | -| Zen 免费模型报 `403 FreeTierError` | 自动恢复**网关来源头与工具回退** | +| Zen 免费模型报 `403 FreeTierError` | **照常列出,由网关用自己的话拒绝。** 请求头与工具回退会为付费路由恢复,但它们不是这个错误的原因——在全部就位的情况下实测,Zen 的 `-free` id 仍返回 `403`,而付费模型返回 `200`;拒绝来自控制台背后的服务,而不是请求携带的任何内容。所以这些 id 留在选择器里,拒绝以 `400 free_tier_restricted` 呈现,而不是变成一行被隐藏的模型 | | 一条路由只能说一种形状——基础问题解决后,其余模型返回 `500` | **逐模型路由**——按目录里每个模型自己声明的 SDK 派发到对应端点 | | 会话 ID 被拒并返回 `400 MissingSessionID` | **确定性的 `ses_…` 会话哈希**与跨轮次亲和 | | 子代理丢失对话上下文 | **父会话跟踪**(`x-opencode-parent-session-id`、`x-parent-session-id`) | @@ -159,13 +159,13 @@ Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在 | models.dev `provider.npm` | 模型数 | 协议 | 服务自 | | :-- | --: | :-- | :-- | -| _(缺失)_ | 27 | OpenAI Chat Completions | 你配置的路由 | +| _(缺失)_ | 24 | OpenAI Chat Completions | 你配置的路由 | | `@ai-sdk/openai` | 31 | OpenAI Responses | `opencode-responses` | | `@ai-sdk/anthropic` | 17 | Anthropic Messages | `opencode-anthropic` | | `@ai-sdk/google` | 8 | Google Generative AI | `opencode-google` | | `@ai-sdk/mistral` | 1 | _(本插件不服务的方言)_ | **不提供** | -数量为 `models.opencode.ai` 中 `opencode` 的 80 个**在用**模型,统计于 2026-10-23 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 80 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 +数量为 `models.opencode.ai` 中 `opencode` 的 81 个**在用**模型,统计于 2026-11-03 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 81 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim -- --write` 重新生成该文件(不加 `--write` 时只做检查)。 插件把这两条内部路由同时挡在模型选择器和 _Settings → Models_ 之外。**三条性质今天就成立,还有一条尚未成立:** @@ -202,7 +202,7 @@ opencode-responses: | **交互式多轮对话** | 通过每会话稳定的 `ses_…` id 提供 KV 提示缓存亲和 | | **子代理与分叉** | 子、父会话都参与哈希;谱系由父级请求头携带 | | **Agent 团队** | 跨编排轮次保留共享工作区的归属信息 | -| **实验性 Auto Review** | 即使后台审计调用省略 `sessionId`,仍会得到确定性的轮次状态、请求头与工具回退 | +| **实验性 Auto Review** | 即使后台审计调用省略 `sessionId`,仍会得到确定性的轮次状态、请求头与工具回退——而且它们的路由取自会话,所以会话正在用哪个模型,它们就跑哪个模型 | --- @@ -217,11 +217,11 @@ opencode-responses: | 网关的 `/models` | **这个账号**实际能用什么 | | 发现装饰 | `discoverModels` 该回答什么 | -合并是**增量式**的:适配器给的行保留,规范行只在缺失时追加,只有当按 provider 记录的退役名单点名某个模型时才省略它——那是事实,不是猜测。显示名与价格取自目录(`models.opencode.ai`),因为网关列表只给 id,别的几乎没有。 +合并是**增量式**的:适配器给的行保留,规范行只在缺失时追加,只有当目录(registry)把某个模型标为 `status: "deprecated"` 时才省略它——那是事实,不是猜测。显示名与价格取自目录(`models.opencode.ai`),因为网关列表只给 id,别的几乎没有。 有两点你在使用中会注意到。**冷启动永远不为空**——内置 shim 在第一次刷新前就能回答,选择器立刻有模型可选。而且**目录里没有条目的模型照样出现**,费率位置显示 `—`:既不猜,也不会退回去显示上一个模型的名字。 -两个生成文件都在 CI 里校验(`catalog:shim`、`limits:shim`);厂商数据变动时它们以非零码退出——这是唯一能发现厂商注册表在你背后变了的办法。 +两个生成文件都在 CI 里校验(`catalog:shim`、`limits:shim`);厂商数据变动时它们以非零码退出——这是唯一能发现厂商注册表在你背后变了的办法。只有当拉取请求改动了生成该文件的代码时,CI 才会因此失败;否则只给出警告,由每晚的任务以单独的拉取请求提出刷新。 → 协议路由、合并与界面的技术细节: [`docs/protocol-routing-and-merge.md`](./docs/protocol-routing-and-merge.md) · [English](./docs/protocol-routing-and-merge.md) @@ -249,7 +249,7 @@ opencode-responses: | **模型与免费额度** | 用 OpenCode 官方目录补全模型信息(默认开启) | `on` | 把目录的上下文上限、模态与思考档位补进模型信息,并让网关 `/models` 列表带上完整目录。**不会**往选择器里添加模型——选择器只显示已声明的;要添加请用卡片的**检查目录** | | **模型与免费额度** | 自动补全核心工具(默认开启) | `on` | 补上免费层 `/responses` 请求体所需的 `read` + `bash` schema | | **配额计量** | 开启输入区用量计量(默认开启) | `on` | 在输入框停靠栏挂载实时计量表——Go 额度环,或 Zen 会话消耗 | -| **账户与端点** | API 端点 | `auto` | 请求发往哪个端点家族:**自动**(跟随目录) · **旧端点**(`zen/v1`) · **新端点**(`inference/…`) | +| **账户与端点** | API 端点 | `auto` | 请求发往哪个端点家族:**自动**(同新端点) · **旧端点**(`zen/v1`、`zen/go/v1`) · **新端点**(按协议的 `inference/{openai,anthropic,google}/…`,Go 为 `inference/go/…`) | | **账户与端点** | 凭据来源 | `auto` | 多个密钥同时可用时以哪个为准:**自动** · **优先实时请求** · **优先已声明密钥** | | **账户与端点** | 读取 OpenCode 安装版的密钥(默认关闭) | `off` | 允许本插件读取 OpenCode 自己保存的凭据,且仅作最后手段——默认关闭 | @@ -257,7 +257,7 @@ opencode-responses: 一个请求发往哪里由两个问题决定,答案就在**账户与端点**分区里。 -**发往哪个端点家族。** 网关在两套端点上都应答——较早的 `/zen/v1` 与较新的 `inference` 家族(其 base 按协议区分:`…/inference/openai/v1`、`…/inference/anthropic/v1`、`…/inference/google/v1beta`)——而两者的授权是**彼此独立**的:在一边可用的账户不保证在另一边可用。`auto` 会跟随网关自己的目录,目前它指向较新的一套,因此目录迁移会带着你一起走;`legacy` 与 `modern` 则固定其一,适用于账户只在较早一套上可用,或你想确认较新的一套是否可用的情况。 +**发往哪个端点家族。** 网关在两套端点上都应答——较早的 `/zen/v1`(Go 为 `/zen/go/v1`),所有协议共用一个基址;以及较新的按协议划分的 `inference` 家族(`/inference/openai/v1`、`/inference/anthropic/v1`、`/inference/google/v1beta`,Go 为 `/inference/go/…`)——而两者的授权是**彼此独立**的:在一边可用的账户不保证在另一边可用。`modern` 将每个协议发往各自的 inference 路径;`auto`(默认值)是 `modern` 的别名;`legacy` 固定使用较早的一套,适用于账户只在那里可用的情况。 **以哪个凭据为准。** `keySource` 决定插件可见的多个凭据源的优先级——`auto` 优先使用你声明过的密钥,其次是从实时请求中捕获的密钥,最后是声明的凭据引用;`request` 把捕获到的密钥提到最前;`configured` 则相反。其下的 `readOpencodeStore` 允许插件读取 OpenCode 自己保存的凭据,且**仅作最后手段**。两者默认分别为 `auto` 与关闭,因此在你说要改之前一切照旧。 @@ -270,9 +270,13 @@ Go 用量 状态正常 ● 5 小时 0% 已用 · 4小时58分钟后重置 100% 剩余 ● 每周 28% 已用 · 3天2小时后重置 72% 剩余 ● 每月 14% 已用 · 11月7日8点55分重置 86% 剩余 -月度额度 mimo-v2.6-pro · Go $60 · Plus $120 ⓘ +月度额度 mimo-v2.6-flash · Go $60 · Plus $120 ⓘ ``` +**这个版本只带 API 密钥路径。** 没有登录行,也没有设备流:凭据来自部署的配置、来自从实际请求中捕获的密钥,或者——在开启 `readOpencodeStore` 时——来自 OpenCode 安装自身的存储。Console 登录(设备流、会话存储、凭据池)在 `v2` 线上,刻意不放进 `main`,因此这里不依赖宿主可能没有挂载的服务。 + +**如果更新后卡片看起来还是上一版,说明宿主进程没有重启。** 宿主包只在进程启动时读取一次,所以浏览器刷新仍在运行上一版的 `lib/index.mjs`;请停止并重启 `dsh web`。这一行是最快的判断办法,因为它和其他所有改动在同一个版本里新加入。 + 「剩余」是百分比,不是美元数:`/usage` 每个窗口只给一个百分比、完全没有余额接口,而套餐档位也无法探测——任何美元余额都会是猜的。这一点由 ⓘ 说明。读不到配额时它明说读不到,并且不打印任何数字,而不是打一排会被当成真实读数的 `0%`。 ### 输入框停靠栏计量表 @@ -361,7 +365,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 | `userAgent` | 空(= 规范 CLI UA) | 字面覆盖;默认值就是网关所期望的值 | | `originClient` | `cli` | `x-opencode-client` 的字面值 | | `sessionIdEnv` | `OPENCODE_SESSION_ID` | 指定一个仅当调用方提供会话 id 时才使用的环境变量名 | -| `freeModelMarker` | `free` | 模型 id 子串;`*` 强制启用回退,`''` 关闭回退 | +| `freeModelMarker` | `free` | 模型 id 子串;`*` 强制启用回退;留空则沿用默认值;目录标记为免费的模型始终适用 | | `usageBaseURL` | `https://opencode.ai/zen/go/v1` | 端点覆盖;从组合中自动发现 | | `credentialOrg` | — | 账户凭据所属的组织 id,在部署从带外提供一个时使用 | | `debug` / `debugFile` | `false` / — | 诊断用 JSONL 日志,不是谁会在 UI 里调节的行为 | @@ -404,7 +408,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 | 症状 | 可能原因 | 解决方法 | | :-- | :-- | :-- | -| 免费模型报 `403 FreeTierError` | 网关请求头被剥离,或缺少工具定义 | 保持**恢复 User-Agent**、**注入客户端来源头**与**自动补全核心工具**开启 | +| 免费模型报 `403 FreeTierError` | 网关的门槛不在请求里:用真实账号、它的会话令牌、它的当前组织以及官方客户端发送的全部请求头实测,免费层模型仍返回 `403` | 上面三个注入开关(**恢复 User-Agent**、**注入客户端来源头**、**自动补全核心工具**)仍值得保持开启——它们让流量可被识别,并恢复工具定义——但单靠它们打不开免费层。付费模型配上密钥即可使用;排除了什么、如何排除,见 [docs/opencode-1.19-api.md](./docs/opencode-1.19-api.md) | | `400 MissingSessionID` | 未附带会话请求头 | 确认 `dsh-opencode-patch` 已列入 profile 的 `dsh.profile.bundles` | | Auto Review 报 `TRANSPORT: Connection error` | 更新后宿主进程未重启 | 停止并重启 `dsh web`,让新的 `lib/index.mjs` 加载 | | Go 模型始终不显示额度环 | 解析不到 Go 凭据,或当前激活的网关不是 `opencode-go` | 在 DSH Credentials 中存储 `OPENCODE_GO_API_KEY`,并经 `opencode-go` 路由 | @@ -414,28 +418,37 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 --- +## 1.x 变更 + +- **更小的公开 API。** 包入口只导出 `apply`、`Config`、`inject` 和 `name`(宿主读取的名称),以及配置类型(`PluginConfig`、`ResolvedPluginConfig`、`ModelOverrideConfig`、`ModelRouteConfig`、`EndpointFamily`、`KeySourcePolicy`)。此前额外重新导出的其余内容现在都是内部实现。 +- **`./src/*` 不再是导出路径。** `src/` 从未包含在发布文件中,所以这个子路径只在仓库内可解析,安装后并不存在。 +- **`internalRoutes` 已移除。** 按协议划分的内部路由总是会挂载。仍带有 `internalRoutes` 的 profile 配置行可以通过校验,该键会被忽略。 +- **`@viztor/dsh-opencode` 不再发布。** 其最后一个版本上的 "Renamed to @viztor/dsh-opencode-patch" 弃用提示即为最终信息;请改装 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。 + ## 📜 兼容性与验证 -**已在 DeepSeek Harness `0.2.1` 上验证 —— `alpha` 与 `rc` 两个通道(Node 24+)** +**支持范围:DeepSeek Harness `rc` 与 `alpha` 两个通道的最新版本,无论通过 npm 安装还是随桌面应用发布(Node 24+)** + +**支持策略。** 本插件支持**两个**通道各自的**最新**版本,以它们实际发布的形态为准:`rc` 即 `dsh@latest`(当前为 `0.2.1-rc.5`,其桥接声明 pi `^1.1.0`),`alpha` 即 `dsh@alpha`(当前为 `0.2.2-alpha.2`,pi `^1.3.0`)。npm 安装运行的是**未打补丁**的 pi,版本为桥接范围所允许的最新 `1.x`;桌面应用随附的是 harness 自身工作区锁定的 pi(`rc` 上为 `1.1.0`,`alpha` 上为 `1.3.0`)。CI 在每个拉取请求上,于两个通道的 npm 安装上运行单元与离线 mock 套件,并每晚在各通道的最低 pi 版本上运行。本插件既不修补也不重新实现宿主 pi 的行为:宿主的 pi 若缺少网关所需的能力,那是宿主的限制,会被记录并报告给上游,而不是在这里绕过。 -Harness 按 `alpha` 与 `rc` 两个**通道**发布,它们是平行的发布线而非版本先后:semver 中 `0.2.1-rc.2` 排序在 `0.2.1-alpha.5` **之上**。两个通道的最新版本都是 **`0.2.1`**,本插件针对这条线——对两个通道中每一个已发布的 `0.2.1` 构建都做过验证,`@deepseek-ai/dsh-typert-protocol` 的 peer 范围正是这么写的: +Harness 按 `alpha` 与 `rc` 两个**通道**发布,它们是平行的发布线而非版本先后:semver 中 `0.2.1-rc.2` 排序在 `0.2.1-alpha.5` **之上**。本插件在两个通道上都针对两条线:**`0.2.1`**(`rc` 通道的最新版本),以及 `alpha` 通道目前发布的 **`0.2.2`** 预发布版本(测试套件的默认安装运行的是 `0.2.2-alpha.2`;其 `rc` 安装运行的是 `0.2.1-rc.5`)。`@deepseek-ai/dsh-typert-protocol` 的 peer 范围正是这么写的: ``` ->=0.2.1-alpha.1 <0.2.2 +>=0.2.1-alpha.1 <0.3.0 || >=0.2.2-0 <0.3.0 ``` -一个范围就覆盖了两个通道的整条线:下界 `alpha.1` 排序低于同线的所有 `rc`,因此无需单独枚举 rc。反之,只写 `<0.2.2` **不行**——除非同一 `major.minor.patch` 的某个比较符带有预发布号,semver 会把预发布版本排除在外,这正是"按一个通道写的范围会悄悄排除另一个通道"的原因。 +第一个子句覆盖两个通道上的 `0.2.1` 整条线:下界 `alpha.1` 排序低于同线的所有 `rc`,因此无需单独枚举 rc。第二个子句**并不多余**——除非同一 `major.minor.patch` 的某个比较符带有预发布号,semver 会把预发布版本排除在外,所以只写 `<0.3.0` 会悄悄丢掉所有 `0.2.2-*` 构建,alpha 与 rc 都一样。两个子句都止于 `0.3.0`,下一条线因此是一个单独的决定,而不是悄悄放宽。 | 层面 | 目标 | | :-- | :-- | -| **Harness 版本线** | **`0.2.1`**,`alpha` 与 `rc` 通道均可(peer:`>=0.2.1-alpha.1 <0.2.2`) | +| **Harness 版本线** | **`0.2.1`** 与 **`0.2.2`** 预发布版本,`alpha` 与 `rc` 通道均可(peer:见上方范围) | | **插件包** | [`dsh-opencode-patch`](https://www.npmjs.com/package/dsh-opencode-patch) | | **宿主 profile** | DSH Web profile(`patchReload: live`) | | **声明的路由** | `opencode`、`opencode-go`、`opencode-responses`、`opencode-anthropic` | | **网关** | `opencode.ai/zen/v1`(`/responses`、`/chat/completions`、`/messages`、`:streamGenerateContent`)、`zen/go/v1`(`/chat/completions`) | | **模型目录** | 由 `models.opencode.ai/api.json` 生成;下线判定直接跟随目录自身的 `status: "deprecated"` 标记,不维护任何手工 id 列表 | -| **上游 pi** | **不随包携带。** 桥接在运行时加载 harness 已安装的那份 `@earendil-works/pi-ai`,因此 harness 升级不会与本插件锁定的副本产生版本错位 | -| **验证关卡** | `vp check` 干净、**961** 个确定性测试全绿、完整 schema 校验、消费者安装 + 加载([`scripts/check.ts`](./scripts/check.ts)) | +| **上游 pi** | **不随包携带,也不打补丁。** 桥接在运行时加载 harness 已安装的那份 `@earendil-works/pi-ai`,因此 harness 升级不会与本插件锁定的副本产生版本错位;测试套件运行各通道经 npm 解析出的 pi,以及其最低版本 | +| **验证关卡** | `vp check` 干净、确定性单元与离线网关 mock 套件全绿、完整 schema 校验、消费者安装 + 加载([`scripts/check.ts`](./scripts/check.ts)) | --- diff --git a/channels/rc/pnpm-lock.yaml b/channels/rc/pnpm-lock.yaml new file mode 100644 index 00000000..088722d6 --- /dev/null +++ b/channels/rc/pnpm-lock.yaml @@ -0,0 +1,4500 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.16.1 + version: 12.16.1 + +packages: + + '@pnpm/exe.android-arm64@12.16.1': + resolution: { integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q== } + cpu: [ arm64 ] + os: [ android ] + + '@pnpm/exe.android-x64@12.16.1': + resolution: { integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg== } + cpu: [ x64 ] + os: [ android ] + + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: { integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ== } + cpu: [ arm64 ] + os: [ darwin ] + + '@pnpm/exe.darwin-x64@12.16.1': + resolution: { integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw== } + cpu: [ x64 ] + os: [ darwin ] + + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: { integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg== } + cpu: [ x64 ] + os: [ freebsd ] + + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: { integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA== } + cpu: [ arm64 ] + os: [ linux ] + libc: [ musl ] + + '@pnpm/exe.linux-arm64@12.16.1': + resolution: { integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew== } + cpu: [ arm64 ] + os: [ linux ] + libc: [ glibc ] + + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: { integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw== } + cpu: [ ppc64 ] + os: [ linux ] + libc: [ glibc ] + + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: { integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ== } + cpu: [ riscv64 ] + os: [ linux ] + libc: [ glibc ] + + '@pnpm/exe.linux-s390x@12.16.1': + resolution: { integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg== } + cpu: [ s390x ] + os: [ linux ] + libc: [ glibc ] + + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: { integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g== } + cpu: [ x64 ] + os: [ linux ] + libc: [ musl ] + + '@pnpm/exe.linux-x64@12.16.1': + resolution: { integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw== } + cpu: [ x64 ] + os: [ linux ] + libc: [ glibc ] + + '@pnpm/exe.win32-arm64@12.16.1': + resolution: { integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA== } + cpu: [ arm64 ] + os: [ win32 ] + + '@pnpm/exe.win32-x64@12.16.1': + resolution: { integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA== } + cpu: [ x64 ] + os: [ win32 ] + + pnpm@12.16.1: + resolution: { integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q== } + engines: { node: '>=18' } + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.16.1': + optional: true + + '@pnpm/exe.android-x64@12.16.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.16.1': + optional: true + + '@pnpm/exe.darwin-x64@12.16.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.16.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.16.1': + optional: true + + '@pnpm/exe.linux-arm64@12.16.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.16.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.16.1': + optional: true + + '@pnpm/exe.linux-s390x@12.16.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.16.1': + optional: true + + '@pnpm/exe.linux-x64@12.16.1': + optional: true + + '@pnpm/exe.win32-arm64@12.16.1': + optional: true + + '@pnpm/exe.win32-x64@12.16.1': + optional: true + + pnpm@12.16.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + vite-plus: + specifier: 1.4.0 + version: 1.4.0 + +overrides: + vite@*: npm:@voidzero-dev/vite-plus-core@1.4.0 + +importers: + + .: + dependencies: + '@deepseek-ai/schemastery': + specifier: 3.18.5 + version: 3.18.5 + devDependencies: + '@deepseek-ai/cordis': + specifier: 4.0.5 + version: 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/cordis-plugin-loader': + specifier: 1.0.6 + version: 1.0.6(@deepseek-ai/cordis@4.0.5)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/dsh-app-boot': + specifier: 0.2.1-rc.5 + version: 0.2.1-rc.5(@deepseek-ai/cordis-plugin-group@1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-home-paths@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-launch-environment@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)) + '@deepseek-ai/dsh-client-store': + specifier: 0.2.1-rc.5 + version: 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-client-ui-primitives': + specifier: 0.2.1-rc.5 + version: 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-config-editor': + specifier: 0.2.1-rc.5 + version: 0.2.1-rc.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-app-boot@0.2.1-rc.5(@deepseek-ai/cordis-plugin-group@1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-home-paths@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-launch-environment@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))) + '@deepseek-ai/dsh-llm': + specifier: 0.2.1-rc.5 + version: 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-llm-pi-ai': + specifier: 0.2.1-rc.5 + version: 0.2.1-rc.5(da65ffcbbdf43427b0f0d7deae0abaf0) + '@deepseek-ai/dsh-settings': + specifier: 0.2.1-rc.5 + version: 0.2.1-rc.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-app-boot@0.2.1-rc.5(@deepseek-ai/cordis-plugin-group@1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-home-paths@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-launch-environment@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)))(@deepseek-ai/dsh-brand@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-session@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)))(@deepseek-ai/schemastery@3.18.5)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/dsh-typert-protocol': + specifier: 0.2.1-rc.5 + version: 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@earendil-works/pi-ai': + specifier: ^1.1.0 + version: 1.5.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5) + '@testing-library/dom': + specifier: ^10.4.2 + version: 10.4.2 + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node': + specifier: ^26.6.6 + version: 26.6.6 + '@types/react': + specifier: 18.3.31 + version: 18.3.31 + '@types/react-dom': + specifier: 18.3.7 + version: 18.3.7(@types/react@18.3.31) + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 + '@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 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + semver: + specifier: ^7.8.5 + version: 7.8.5 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + ultracite: + specifier: ^8.1.0 + version: 8.1.0(oxfmt@0.74.0(vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1)))(oxlint@1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1))) + vite-plus: + specifier: 'catalog:' + version: 1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1) + vitest: + specifier: 5.0.5 + version: 5.0.5(@types/node@26.6.6)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(jsdom@30.1.3) + yaml: + specifier: 2.9.1 + version: 2.9.1 + +packages: + + '@anthropic-ai/sdk@0.129.0': + resolution: {integrity: sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@asamuzakjp/css-color@7.1.3': + resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@10.0.3': + resolution: {integrity: sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@aws-sdk/client-bedrock-runtime@3.1127.0': + resolution: {integrity: sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.978.1': + resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.72': + resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.74': + resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.17': + resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.79': + resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.84': + resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.72': + resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.16': + resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.78': + resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.35': + resolution: {integrity: sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.30': + resolution: {integrity: sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.54': + resolution: {integrity: sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.46': + resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.47': + resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1127.0': + resolution: {integrity: sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1138.0': + resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.6': + resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.41': + resolution: {integrity: sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + + '@deepseek-ai/cordis-plugin-group@1.0.5': + resolution: {integrity: sha512-PGYqImAydmAjmy5fl1Js6qDmk0Ivt1fipa3fOj1KyyRd0tZ9i61a2rhzHc7LaSeBb/spJ1LF4k3Pcppaq+w8fw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/cordis-plugin-loader': ~1.0.6 + + '@deepseek-ai/cordis-plugin-include@1.0.10': + resolution: {integrity: sha512-Rdo6Uqf9WfDbfN3dsYkYj/EW+SGuWCSfthz1PoOtZYUr6qrRpqD7ND4Gj3nUYj8XhSGIo8E3xpsck6J5nM5BYQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/cordis-plugin-loader': ~1.0.6 + + '@deepseek-ai/cordis-plugin-loader@1.0.6': + resolution: {integrity: sha512-fr011A782oEjkJXCY4pePt2qaPpkGvem7Rmst6Rhwgg6icgwI4eN9QBRb6S/q9XOYGVQUdvL0wXUWyNxO8WipA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + node-addon-require-builtin: ^0.1.9 + peerDependenciesMeta: + node-addon-require-builtin: + optional: true + + '@deepseek-ai/cordis@4.0.5': + resolution: {integrity: sha512-Sktq5axy0owk9PdnivBRNOJjHEVEibCt0+TDDZcv0+UH2KS1M910fX4kn5y45YFsrpZIi1mJf/1s5VzywMUnyg==} + hasBin: true + peerDependencies: + '@deepseek-ai/cordis-plugin-include': ~1.0.10 + '@deepseek-ai/cordis-plugin-loader': ~1.0.6 + peerDependenciesMeta: + '@deepseek-ai/cordis-plugin-include': + optional: true + '@deepseek-ai/cordis-plugin-loader': + optional: true + + '@deepseek-ai/cosmokit@1.8.6': + resolution: {integrity: sha512-ZSD/fN52L498LBIOYwjIOlaLv5veY6ZEwI2yzvZUKQtctn0QAPqu8tKdJ+OF5+Tv6NBq1U+wJWH+QI6OGy5EvQ==} + + '@deepseek-ai/dsh-app-boot@0.2.1-rc.5': + resolution: {integrity: sha512-8dBUah1r9W86/iXL6IRiluluJecNfyR0FEPR09EAcvqbqGASVM7gDqF/0eUYfPyc1MPN5StOqvaE12qnzWC1jg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/cordis-plugin-group': ~1.0.5 + '@deepseek-ai/cordis-plugin-include': ~1.0.10 + '@deepseek-ai/cordis-plugin-loader': ~1.0.6 + '@deepseek-ai/dsh-home-paths': 0.2.1-rc.5 + '@deepseek-ai/dsh-launch-environment': 0.2.1-rc.5 + + '@deepseek-ai/dsh-atomic-write@0.2.1-rc.5': + resolution: {integrity: sha512-XFN8WAUsBbnfbO+pvL2qgM6Gy1kOqCL0Df01J8Bb7qjL0zSEx7N5IToqOU/tlrryNxp1b+6fxGv+Jvauk9TT8w==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-attachment@0.2.1-rc.5': + resolution: {integrity: sha512-9jPIVpB+B6ipKz3lXrvR4gxe1XU2pl8U9vIEpKTiWHKW4vG3GF7j6vVQge7gtd3mkkBj65hc8pIvedS/ird0FA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/dsh-brand': 0.2.1-rc.5 + + '@deepseek-ai/dsh-authorization@0.2.1-rc.5': + resolution: {integrity: sha512-kcg87CvAmLsdAyxd6gbZ+HhQVCAltD4fynFn/k/zCy6Hp6U+jSi+i9VZmo4e3foTZt6xnWk3ZRjhy00KYUkvIQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/dsh-credentials': 0.2.1-rc.5 + '@deepseek-ai/dsh-llm': 0.2.1-rc.5 + + '@deepseek-ai/dsh-brand@0.2.1-rc.5': + resolution: {integrity: sha512-7lUfWjWWAkrb/CFtMbXgjXLYQnL3KJwv+GLMTgY5EZ/4sOH0ufZUX8xlBWyMpdV2LqdvJ1yPKQ+mopH71/wbMQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-client-store@0.2.1-rc.5': + resolution: {integrity: sha512-CDi+x1DSXVVrSNflGjDa1dovyWbcQ4Gmyz200sk2TYL69X74LyfPrAWkGA66z4v10o4VlRQxFz6hJ5YDFhoi/w==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-client-ui-primitives@0.2.1-rc.5': + resolution: {integrity: sha512-qSsRaEOURuFu4hDtPGcDO1bKiyPbFV68fvzm9Kxp9SG5c5SOzE4b6P33p1Hmml4nocXb7ktA6ay4jH3HEl1LQw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-config-editor@0.2.1-rc.5': + resolution: {integrity: sha512-seSmBfULOLRm/HrXYoS4djj5Mii2btYl8eyoLlMRFCeOwKPvRvFHZXNVJHwbfNmdtFIskdF0Ep4T8a2pkB95Bg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/cordis-plugin-include': ~1.0.10 + '@deepseek-ai/cordis-plugin-loader': ~1.0.6 + '@deepseek-ai/dsh-app-boot': 0.2.1-rc.5 + '@deepseek-ai/dsh-hmr': 0.2.1-rc.5 + peerDependenciesMeta: + '@deepseek-ai/dsh-hmr': + optional: true + + '@deepseek-ai/dsh-credentials@0.2.1-rc.5': + resolution: {integrity: sha512-acO5+ytVNCqiej/5QI2Xy36gjiOrsf2X59rllNbVfA+IpanLeVBMsBQaSB+bKpgU3r4MVwvrz5EvcJuk4FeONw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-fs@0.2.1-rc.5': + resolution: {integrity: sha512-guYuqh8VP0yXRgY0ZhF1kjPykCVNV4FvIQgn5qs6NZsOY1eSnKYGfG4fjlKM7ld0mSB0cAF6qx6Bs2nKD5pUFw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/dsh-brand': 0.2.1-rc.5 + '@deepseek-ai/dsh-llm': 0.2.1-rc.5 + '@deepseek-ai/dsh-sandbox': 0.2.1-rc.5 + + '@deepseek-ai/dsh-home-paths@0.2.1-rc.5': + resolution: {integrity: sha512-yEGBsUON7xynPYAQKHxvRJ5kywht2ByHfEh20j9FkPluopNRtEnlF0f0/mRL1eC9UMVzDUqUArnAY6dWdb2NDw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-launch-environment@0.2.1-rc.5': + resolution: {integrity: sha512-5LdbNaDl5EjoPCwys13AJrT/kq6y8Yl0o/KBEETnLIV6o4PPeimANgmjG7TXF6UjmAaY4dV5jeBBI64+naqNwg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-llm-pi-ai@0.2.1-rc.5': + resolution: {integrity: sha512-zpFGj+Wuy737v7IkJYpnRdKCWV6NxACqDOoeO3zjZMd0zMA9KCN68W65/GPg/upXNINkChWCE7mTdVp8REMI/Q==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/cordis-plugin-loader': ~1.0.6 + '@deepseek-ai/dsh-attachment': 0.2.1-rc.5 + '@deepseek-ai/dsh-authorization': 0.2.1-rc.5 + '@deepseek-ai/dsh-credentials': 0.2.1-rc.5 + '@deepseek-ai/dsh-fs': 0.2.1-rc.5 + '@deepseek-ai/dsh-launch-environment': 0.2.1-rc.5 + '@deepseek-ai/dsh-llm': 0.2.1-rc.5 + '@deepseek-ai/dsh-timeout': 0.2.1-rc.5 + peerDependenciesMeta: + '@deepseek-ai/cordis-plugin-loader': + optional: true + + '@deepseek-ai/dsh-llm@0.2.1-rc.5': + resolution: {integrity: sha512-g9mSILkqY7wByl0tpLBhXxWxQ3MoNzXokuJq86R/snPL3v1OonXv1b46wtvldEgyr88aK1gBBlifMsRnXjMWTA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-package-manifest@0.2.1-rc.5': + resolution: {integrity: sha512-YrlR7mmN3SL5NTM+ls5B3KgYXLP0I+K/7z8khR+vt8VaOArvSe/CuJOIVHoTyJMQX8Cq46s2qGL+5UUQnPP7cw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-sandbox@0.2.1-rc.5': + resolution: {integrity: sha512-QokCxazz+xB9jaSVog6flKQwaNZF9iPak86k9wgeB5m7cki5XFkcKyf3+36G/y3rjGVAMqbmxL86WoP4gLp6xw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/dsh-llm': 0.2.1-rc.5 + '@deepseek-ai/dsh-session': 0.2.1-rc.5 + + '@deepseek-ai/dsh-scope@0.2.1-rc.5': + resolution: {integrity: sha512-KrYnnJwPuHXPRayueGCWqn7Uy5CWph/5Kt2CcubBxMTFqC7bHaHiDNeTQW9HmECczmKLD2U5dFSCTe569s4YeQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-session@0.2.1-rc.5': + resolution: {integrity: sha512-A5NdQFgeY4qi29+lBzgZiOloiFe+E1iBbIRjJTplee79rswYGUUmUZYmOvAcdmk4tERkY2S9htuc+V+jNifqRw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/dsh-scope': 0.2.1-rc.5 + + '@deepseek-ai/dsh-settings@0.2.1-rc.5': + resolution: {integrity: sha512-MKNaTE5Xi9StBeFZ2M2pKWFxC7bGt6rWEPVYeDslKJdDdfUbD5O7C+bN4QvcCjqDBbkdN5xweDZHxj6K0w7XIg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + '@deepseek-ai/dsh-brand': 0.2.1-rc.5 + '@deepseek-ai/dsh-session': 0.2.1-rc.5 + '@deepseek-ai/schemastery': ~3.18.5 + + '@deepseek-ai/dsh-timeout@0.2.1-rc.5': + resolution: {integrity: sha512-tQR1A6sQGG7XruB5zeobk+SUhvrxGYf+62WnMOcgdhrr9wCD4MCojZKBtubbm5BhozwEHAyTeESbk9l2+B/Uyg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-typert-protocol@0.2.1-rc.5': + resolution: {integrity: sha512-wUkcvZ2ZOlAOeI91m4ESOdtp7m01cq0bhCHirLHBsMAa5UGYWwkyijLHvGs8aaM27gooltpEHLquUnl6zFXtkQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-util-crypto@0.2.1-rc.5': + resolution: {integrity: sha512-7mGcnCH0BzIUzbnnC2Vx17Q/O3/+EfMHNt9iC2VaGr9SK010HYUpei/+6wrZsuGy77rqAUNhU6VB35wQTLM/VA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/dsh-util-values@0.2.1-rc.5': + resolution: {integrity: sha512-PL8TerJM/4jpZ2ArwgTpFbeDiAVnqkSh8Xinw2iEu8B6a+axmshzL4dCpwpf+uB8GHdXAhGz1cT+SBQwX/2TLQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.5 + + '@deepseek-ai/schemastery@3.18.5': + resolution: {integrity: sha512-Mnobvqf9rHsZYv4g5edj5ddxS1oF1kIdSlz0DunUgnY3LWG5F26MqvH3YOOsSaodCgyzJ16OMN9O99/s5Crtdw==} + + '@earendil-works/pi-ai@1.5.0': + resolution: {integrity: sha512-VeWxNqTHSqRCf8AcY/6kvNwh+neRL6MPxZPePVztIQy6qqK9MR/YoCSN/ZgDnIyCD3pqEXgRyQbzk4KV4uvjbg==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-schema@1.5.0': + resolution: {integrity: sha512-RW0MbT2tR65OY6xMsgB/yVBb7m/RTNwA+9hurSN1vVXfHZRVmVnXbruBPrx/PzbrQnW5yLVlMRWxErGDQtF1Hg==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-telemetry@1.5.0': + resolution: {integrity: sha512-VW3tcVdnNfl/Vk8wHPTzZdKkxcsCOM12ppiO4DXnd9wMxUCmOfLt/wvUnWeKWbTVlVSRg0uqKtnnhE2UCsG2Bw==} + engines: {node: '>=22.19.0'} + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@google/genai@2.21.0': + resolution: {integrity: sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/runtime@0.155.0': + resolution: {integrity: sha512-VbW9raczGS5ZZ1FMunUBYHe4uV/R684qlQCoMdE3n5JT1PcqQsvmFpMYkF8AD+W04fBLGAq6Tr7YsTk0jjvDBA==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} + + '@oxfmt/binding-android-arm-eabi@0.74.0': + resolution: {integrity: sha512-xxQwrf5EPI5zxvuCTW8r6tzCTjPQ+h6+HqJ0iRKRy2zS1yBllt/xLVjoIyUc+FnR7g0a5g1SZv2yyDYhTItLbA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.74.0': + resolution: {integrity: sha512-FJW3sATVvrWbV64al8xofCPZj4500Z/1W0ad7Tg1rcCUyh0EMbuSrsf3jsZS7CEbwQ5fQ0mRhmkc129GFgofSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.74.0': + resolution: {integrity: sha512-TfmNKBD30dGyMu9GCLiaw2rt/z7fms9LS+5cKcNOyWst0QgAs8oZs6wPxMbVaaDVE0/PCdCosgWM1vbu6f5NUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.74.0': + resolution: {integrity: sha512-5Usf8+DfC4Q3o/MD8FkN47GrI6y0KbhQ4KhZ2HGzMZcXjcW1AeldwbUn51aKih+1TVuJFF9qkNm6X1nzm6NYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.74.0': + resolution: {integrity: sha512-eL+1O3gykw1XihbZh2MRAciQvU2988LByrnjhyo/dmNG74X4oZ7XzigRoWPRFLfJCp8hzRkp/LbEhwe7uuT2eA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.74.0': + resolution: {integrity: sha512-Xt0QtO2hJ4stnul8IL4LJGVLyp0xnc6ccN9fZ30yuubv8FWSRYxWwd1Za62KsEqAfmPSazoS/cLSUUJpja6npA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.74.0': + resolution: {integrity: sha512-/4wYQ0gZl4vUR0QoBTGaay73FmQ1GAjUa9z9101TR2xbGNrg4a+grazlR0yzEOe3/E+PJr5BGfj66MMzm/54vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.74.0': + resolution: {integrity: sha512-846bKzF+HJd+JkOFh3rxzmYvSq4Cm+GMT+p+mW7S13ZYo6GAAGv8bL8cAOi/XcdeMqxEJDCIMTVQLVBXHMCdig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.74.0': + resolution: {integrity: sha512-nI5FJwZ4Flo6CBdscpwxymZXxO3m3IErQJX55ZS6h+ObfGXu8FostL85L/RWPFaCLnIVE4/WfLw+2q8XvVEeUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.74.0': + resolution: {integrity: sha512-BoQYKuqUdsKAFAkK6DLBvkdv4+t0hppSm79sB0wIcE2o/eOwNj+6Z3OlKY4q3Eta/tlssP65FaS9vHn8TeF7vQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.74.0': + resolution: {integrity: sha512-FSroRrM12+jCdSQklF9aOxsKpI6wFs4ZCo/1cSYG80RWZS6z4LV+l9tXRMT8lDGu+yohIZBwi9M76FzGPFDE2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.74.0': + resolution: {integrity: sha512-0VLQD0dhGBLY9Ik/w+otMvD81D0YXi4VDwql65macV+O9UzmSWmUr6CKf+dMtysDeC0PnG3y6kBkRO08BJx52g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.74.0': + resolution: {integrity: sha512-6X7BbZNz34+OHM6Oqjf6TRcUyRn6aRcB4VtIDZAF/uQRyeVhg+G2hv6MlMvmnRekVSyTJ/8rG/u8W2dRFW6glg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.74.0': + resolution: {integrity: sha512-4kYIQK53yJXrxwxSzx8H2bojlf6/KezLGYLRXzV9QCCkoiCsvBTCid9Bg83dUPTLPa3g9K0730f6MKDbck1+OA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.74.0': + resolution: {integrity: sha512-MfWz4wDREjp/JQDmuxtKieix4FHGa1z5rBvB3betdcPwZvf5fqeQrlCm1mmoyuUVQOh9Pt3M61HwN94kJCxw7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.74.0': + resolution: {integrity: sha512-6rmAApkq5s1kbDjvNI6Nl5A+JqQkLORb2sR941pf5HWsrd01WGKyagK/B4vX9+kh1FfvRkNgEu59FHvVk4H8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.74.0': + resolution: {integrity: sha512-+5SpRUnZNGeXTyumHnpHa791tQtt04qZs076WT8eTcGF2OJ631VLnf7S0kQSPuDG+PtJM0phadEB2WAr5n1w9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.74.0': + resolution: {integrity: sha512-1Fg+wYBO3Dsti1QX+/6EYHjeQzLC84tLOAqPu/r7ah3UJlUs9C/DB703Ay9Vcg+8VMzqZA2P5VGhzJraVUD4OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.74.0': + resolution: {integrity: sha512-y9jXUgPBm2TZIWbQhMYRRYAQM57+r4fbykgc+j24ICIIZl+pcuoNhnq1CRGm3oFiHnEUnZO00qOtSPGQdJ2ziQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@7.0.2004': + resolution: {integrity: sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@7.0.2004': + resolution: {integrity: sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@7.0.2004': + resolution: {integrity: sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@7.0.2004': + resolution: {integrity: sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@7.0.2004': + resolution: {integrity: sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@7.0.2004': + resolution: {integrity: sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.89.0': + resolution: {integrity: sha512-Pflpba5F3gRAQ12sWglRxvWTHEdAusNDvmi4Y5N1T5SY1AGkM7AixOnET8qkrnLJ/F32qAlNokO6eCknT8tmnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.89.0': + resolution: {integrity: sha512-dYmyD1x7LaPlb79vZWUcpTShk8dI9z1I+IubtqrBxWNJaDhQdPHwcsvg7q5StTU4xWyd9AwPfLaTYFZY8NZgtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.89.0': + resolution: {integrity: sha512-Z/oxOPiIqiS7fItRdxaaOEC+6GD3OpGi0IxoR0dMefzQHolsKLzScw2DNcu/5gUPok3tv+G9mslvYB5bo3i0qg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.89.0': + resolution: {integrity: sha512-eAjTkHSjAxrdCPJxO6y5sPQL5pYLKb2kvxZz9UU7tXnOZk9I/HLtkvx9S7GHY883k+AmL/eC7mqFt+w7WCwk0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.89.0': + resolution: {integrity: sha512-0lNCA9Tl1n1FKpqy2/9ckp0kIuSrtJ/l9bMRgaEhOtM7TsOHshFuwOcvW8oA9teXLMOJT61LjEKWiaHDFGxF/w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.89.0': + resolution: {integrity: sha512-OTFEvi9W+JczaZD1Caj17SGCoW8H7TE+fvx0D7ATEWa1Lohn91UMxFQ6r145SiGy/yCxeIwptMys/RAM7gSVSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.89.0': + resolution: {integrity: sha512-fRxgNHvBLswj6MrhW2uBq5UDhPRQlCbHeq/Nf8yuG9zOxhDsXqX/mi2PX1axgsQxMm88el7HLiJAzglAnflYxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.89.0': + resolution: {integrity: sha512-v9ENzHvwrQoYSWTtviH1pOYc7R9UovDxVofnck50cNmPihRRTcrg5X0UlRbLOZ7IVtxPOlh2hb7kDO+FpE8SfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.89.0': + resolution: {integrity: sha512-vfffe8mQ484AzVadlUOFpgL+JDwhvs9hrzxTgQP1FVfi4ZEUUYeffXlzC9dFV7Dgnu5s/KrNKvfmG6HxBd/ISQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.89.0': + resolution: {integrity: sha512-Buonlp4hVS7RGNP5M7bPpGNQrenyzA2C64JcO/oz9C1c6dGOXJ7VEAnaiAMY7voou0JYyCJdvDVlFQc5DZX3DQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.89.0': + resolution: {integrity: sha512-1eVwJJrJ3lYIPT+cm7BcBBZZXY6IQZP/oyonaruTor/MrVyWOpvnwt1oD5CLdsHg5aVBIxXodQSvt+1gK6ck5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.89.0': + resolution: {integrity: sha512-fRQQsKUGShdEL5Fl9o9uhj3grkZbeXKLv0icQ5tspUCNYSDy8wzzEcP1WC2bKdTQ0rSszbKlO3iiHORxV22DLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.89.0': + resolution: {integrity: sha512-dEtYjIy6+fHcjKklsmtmMkZRhwHr2UbzjmpvuPtQYSeN9awQ2hx5JLms5Wvri+8P/NZtD8v5hfgHALUql+/Ycg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.89.0': + resolution: {integrity: sha512-hqjwIj5KOYM8Wjef/p7IruNi1iByLhSzbMBk62y22P/E8q7Jj0Pp+DeUTKTRYyZYCDtU1wEGlnWOnttHbyNIww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.89.0': + resolution: {integrity: sha512-iAQN3e6o9UgUCcdGwqRx/jYcKbG3HK7ur3rH6lTqVcQeNGZ85AeWIg6cgn/edwFQ0ofEZZItMwkzyCVTahnvEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.89.0': + resolution: {integrity: sha512-MNxjEByz3N/7Q4qFQuPWvc14TpN9LoD3pECEDsJTZbP/wyB7U+hduUXEl7QAbbgVezFwsb9sWtNUO6XVBndOhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.89.0': + resolution: {integrity: sha512-xxe+i9wfuFso/VnoL4mfj0UlzE+cfCFiUB06vkXCMyQdgVVmItF5FfAF4tATAktSU6uHruWR+FjqvAT9cHZZzg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.89.0': + resolution: {integrity: sha512-saFy07cO94E664glwgLmKLI40HgTL1PY86y4T6+ud4niX4H0Kdqjcmqmjps7mQNdBytV5guGbXDdyQtRfeLbLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.89.0': + resolution: {integrity: sha512-hLFJ/o0f/dTJh/EW0vmcbREYSQO9tYgfiAAlsOkiLFiJuRRWvYtFELUXN5o2ijpSo/tlO1n+YlWN58tZwPG18A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/plugins@1.89.0': + resolution: {integrity: sha512-TJBBNl4KIxbxrSk7gsqlz6iM1kxidfyTizEzfWqXvijF2Ou8/Da58GvjemBVmCmaUamwRij+JwsVc0kpBFuqfg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@smithy/core@3.35.2': + resolution: {integrity: sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==} + engines: {node: '>=18.0.0'} + deprecated: Deprecated due to a bug in custom logger data redaction. See https://github.com/aws/aws-sdk-js-v3/issues/8393. + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.4': + resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.19.0': + resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==} + engines: {node: '>=18.0.0'} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/browser-preview@5.0.5': + resolution: {integrity: sha512-ViNg4UlomGSJHtnnr7Txm+55P7/aAMMoS+1WYnWjp5w5tzf7YEUAICcs2sb1uMovEnC8el/8zUfkWZ8+7fPCKA==} + peerDependencies: + vitest: 5.0.5 + + '@vitest/browser@5.0.5': + resolution: {integrity: sha512-IDMoZX/iZ8rkilw7weaSgN8tJ7iZ9hUv0wYoKXyiyjWjWFwLSWAY707DcVTgxTYOudWC1E3HA9LYn7kVa9a7uQ==} + peerDependencies: + vitest: 5.0.5 + + '@vitest/coverage-v8@5.0.5': + resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==} + peerDependencies: + '@vitest/browser': 5.0.5 + vitest: 5.0.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.2': + resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.2': + resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + engines: {node: '>=22'} + + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@5.0.5': + resolution: {integrity: sha512-24sSdIlMA9DVPXnYiB9siwU0bTd887Y9jX3+vabztCSsvHwo7ipvJ/a2L3Rljw8+2rK1f5fCj3h5U9I/Jsqgbw==} + + '@vitest/snapshot@5.0.5': + resolution: {integrity: sha512-dHyFElWMQy0wFBGrIaPPeUWfJFh+jAUrv46ImIJGd8CADdKV3mlrAu3FyD0wesdxELFCs9M807e040Kba/XKtA==} + + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + + '@vitest/ui@5.0.5': + resolution: {integrity: sha512-dCoGUOdgmCaSB2g+WQ/spPCBkytAWdNUkFSXY4z+eYp2d8JEOY8v0uDRaJTG9qf/kTlTVZ4CuYMYMz/f1HS/qQ==} + peerDependencies: + vitest: 5.0.5 + + '@vitest/utils@5.0.5': + resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==} + + '@voidzero-dev/vite-plus-core@1.4.0': + resolution: {integrity: sha512-LTGXCdZ58qJKfuCOmf0hnZOVA64eOgzDQOqYpI+71tPxk/0cGqPQhYDGQQZoEvNLAn1G2Lo4gKadsQdWxb99WQ==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@types/node': ^20.19.0 || >=22.12.0 + '@typescript/native-preview': '*' + '@vitejs/devtools': ^0.7.1 || ^0.8.0 + '@volar/typescript': ~2.4.0 + '@vue/language-core': ~3.2.0 || ~3.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + publint: ^0.3.8 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: '>=0.5.0' + unrun: '*' + vue-tsc: ~3.2.0 || ~3.3.0 + yaml: ^2.4.2 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@types/node': + optional: true + '@typescript/native-preview': + optional: true + '@vitejs/devtools': + optional: true + '@volar/typescript': + optional: true + '@vue/language-core': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + vue-tsc: + optional: true + yaml: + optional: true + + '@voidzero-dev/vite-plus-darwin-arm64@1.4.0': + resolution: {integrity: sha512-5Ie3vnrFrF5GFU+0VhbLuyoSmnzd8m42BvbLjncMOmAb6D1sEq4g37dlxPiVcJEqITM0t4zFnLkNSF4YAlk0FQ==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@voidzero-dev/vite-plus-darwin-x64@1.4.0': + resolution: {integrity: sha512-vaR7F+ix/91EycJYXOQ24Nzw470U12x3L+YLbbtuRwHDMW67fCbhXzcW+otZS8q4eRdIzEcA5svv9P8kcsaRkA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@voidzero-dev/vite-plus-linux-arm64-gnu@1.4.0': + resolution: {integrity: sha512-58/PZrp+f3Hy6kFVKqbrZVenjo5t012rTOgRSgMoJyOhJjXBHqvORh9lB9GIip9r9maoWN31R1a44obPX9MD7g==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-arm64-musl@1.4.0': + resolution: {integrity: sha512-Ij08vPrxG1J23wQVm0LtLPrQevv4tos8TirP/jGTpQQmQAgYOpmIfMrx8BF+vOGMncOIo+kKacp4jPVmhctfhg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-gnu@1.4.0': + resolution: {integrity: sha512-RbM21Y5cSx5WesaFV/Swo22JRvimjO6NDItoL5wNI6NTa987ck7v19HBmp+V4nkOCFHbMdxwfhyALDw0y06xEA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-musl@1.4.0': + resolution: {integrity: sha512-Q+p+YJztEmqjJoumTmPaBQ/PEx3eFhiNTQTn5+85gkBUiRexkFQ8A86TMFxbKVgrJbiAOltkrJRmVRhRs5Q0sA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-win32-arm64-msvc@1.4.0': + resolution: {integrity: sha512-PLGVov8c4/QfveOM/AYjYDlgMCQ0SxCmJp392qBgxaSXP0nEg6yP4hn5UtnhxvluhHEzVkTf2r1Q3LNlZRI7kw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@voidzero-dev/vite-plus-win32-x64-msvc@1.4.0': + resolution: {integrity: sha512-DYG7wDsMLLs2/+8qfeyLScdoMh+aPM4IZanoVPSNt8fMeeXR2GQiiZDKc0qZHKN9+YcdCGK1HLAdMAGSywKvsA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@yuku-core/binding-android-arm64@0.19.0': + resolution: {integrity: sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw==} + cpu: [arm64] + os: [android] + + '@yuku-core/binding-darwin-arm64@0.19.0': + resolution: {integrity: sha512-7sNJa6XJhyetnTy64jw3NEi4YgUcezHk1G5GH9FSvXAuC8wIlbaOwT9lgq5qo5sspjrZJQlBR1TWflP49uRpMw==} + cpu: [arm64] + os: [darwin] + + '@yuku-core/binding-darwin-x64@0.19.0': + resolution: {integrity: sha512-pTC7dYEc59isdyizc3nsuyFNBu2LU5wMYZ1be6UMr59PY/AJNtO1aBLWV0MgL/z7C1nv/2sX4TNjGgKLoeevJw==} + cpu: [x64] + os: [darwin] + + '@yuku-core/binding-freebsd-x64@0.19.0': + resolution: {integrity: sha512-xrZjF6XOEwyLOiDIfrwDdRCS6ghxcJeirNC1KivQhVcUwlRonaU79S3/Uiyx3lBylPnT8qKJ3/aMT+H52nGTJQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-core/binding-linux-arm-gnu@0.19.0': + resolution: {integrity: sha512-0OyIH53C0I1CbADHn9FSdCfX5qbpeLwJhmOVhonFJNYheybOnkSJ50qGMfTJIb8y5zrwqvLs7FVBvz6Fk8maZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-core/binding-linux-arm-musl@0.19.0': + resolution: {integrity: sha512-z/iPfkCOCSXE34VXNt0uP1ORSW+4mEa9b5CiJcWZeqZH08g49bFU/jRM2T04f+Wpw5mJ6HIzNBiojxeAbrHjeg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-core/binding-linux-arm64-gnu@0.19.0': + resolution: {integrity: sha512-jLNTi+gzaRwBJ+gwhCJ+rDb5dmx/8hEBx93UWd7hjMSqPTL1D1wvVSvXVAldV5kALvju7b0/m51EHEhs4+I1fA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-core/binding-linux-arm64-musl@0.19.0': + resolution: {integrity: sha512-YwyPIdW/rN2Fmv0MTFWi47FaB3F9g+2b+sJISyAw83Yd60RWH4T3Nx6XkAHHVN+uKJNBAd6So7/BVihSiFYvuQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-core/binding-linux-x64-gnu@0.19.0': + resolution: {integrity: sha512-/vskaAXVX3ESR43FiTdfiYS7z1b9uoQJwVQ/0RgUHgN2sWoAyCZs+aX4CgU7eKGIHTVfp9VFwv+c+2XchYiEtQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-core/binding-linux-x64-musl@0.19.0': + resolution: {integrity: sha512-FuXL5igeSKib9Rye02TdH+D6SQezTyGxIATMu1Qsn/QywrzwpGl35OLpS2C1x5vpGLeVE2XgiLd5mVPFHmIglQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-core/binding-win32-arm64@0.19.0': + resolution: {integrity: sha512-YuJAe7zEKT1Xa7LuHlhGJpPnYQeVtvo0rZvNjJRqkH0hQGOw4C8QeP30Tg5VKGzZfo2x8CjhjEhKt4TmLwfmdg==} + cpu: [arm64] + os: [win32] + + '@yuku-core/binding-win32-x64@0.19.0': + resolution: {integrity: sha512-o8iY/mXwlRkedp0LNTr4+TEfXTPQwoNZmv6k+1WIAd1ZXEn8xJJTBCtB0osaVvdZo/x/cNmV16UrWrrGpM5Mvw==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.19.0': + resolution: {integrity: sha512-tFkNbsP4WXX9UMUC5syJGdK9dPMLK0PPaIuXCcCYH75sZ8MrebfBZ2XvCILRTw8AgLivSSCfDa4R67iYBDa4Kg==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} + engines: {node: ^22.14.0 || >=24.0.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@10.0.1: + resolution: {integrity: sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==} + engines: {node: '>=22'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-workspaces@0.3.1: + resolution: {integrity: sha512-UDkGILGJSA1LN5Aa7McxCid4sqW3/e+UYsVwyxki3dDT0F8+ym0rAfnCkEfkL0rO7M+8/mvkim4t/s3IPHmg+w==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} + + http-proxy-agent@9.1.0: + resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} + engines: {node: '>= 20'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + https-proxy-agent@9.1.0: + resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} + engines: {node: '>= 20'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsdom@30.1.3: + resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + log-update@8.0.0: + resolution: {integrity: sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==} + engines: {node: '>=22'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-native-custom-loader@0.1.9: + resolution: {integrity: sha512-Zh87aubSc1UF8CzZsRws1KtSwXiPDH0tbcg8V/2OA8Tcq9yGJRESFcEOC5NGLFama5IGfC3aJhubZbrj9Un2qA==} + engines: {node: '>=20'} + + node-addon-require-builtin-darwin-arm64@0.1.9: + resolution: {integrity: sha512-n45xEILQVOLZ2hVhzRXesTMYS4odHquyI/B/IkPMPUCONa8PjNfBag6lYBH4wDrhgpLNNBwMsUD8d8O/13QPPA==} + engines: {node: '>=20'} + cpu: [arm64] + os: [darwin] + + node-addon-require-builtin-darwin-x64@0.1.9: + resolution: {integrity: sha512-Sm4Liqh1uo9zgJB2F/pACPPZdHu+fj9Qniyr1Q99dDQB0v71VgZgkAgv3K0/E1iPQBlZq9rakFkY7MGUIfWc0A==} + engines: {node: '>=20'} + cpu: [x64] + os: [darwin] + + node-addon-require-builtin-linux-arm64-gnu@0.1.9: + resolution: {integrity: sha512-ToU4wGtOD/069i2y4fJ3cjDJiiWtcjSQTZMf0YKlotb5BmTM7FjhR3X1aub8J/YjlOwl6665J/XAYuvCaDO5sA==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + node-addon-require-builtin-linux-arm64-musl@0.1.9: + resolution: {integrity: sha512-85Z5Avp++wfa9iiTIH8LC/r6qvlC/CiwpifiwIz0gYQ9PzajTSkz06gX7YDqf1oUqWYxU9uwNEOmo7mktHOOfA==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + node-addon-require-builtin-linux-x64-gnu@0.1.9: + resolution: {integrity: sha512-fh3K3/JvBfSYwU8vEOpp4Wdp4N/jX0EnZYq5GsaftlQin1FVqgve/yDNoh+asxBMOQyHOG8F4dXlV1WCierrfg==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + node-addon-require-builtin-linux-x64-musl@0.1.9: + resolution: {integrity: sha512-B6fu/5xmbRxnifM7nWz0IESkYnUl8dwFZmRDMsxdhzv2nb+EtBRFAXg4lf73qhX3yoZD4SnWJILBQMlIAwBCzw==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + libc: [musl] + + node-addon-require-builtin-win32-arm64-msvc@0.1.9: + resolution: {integrity: sha512-FOQuzNe8B1BANu886rJgdbQuRxPGUQmewkykw87wSr32U0ibQ3AWGuUM/Gh7LG4RekjFaifj7gEI/tq2+6GU6g==} + engines: {node: '>=20'} + cpu: [arm64] + os: [win32] + + node-addon-require-builtin-win32-ia32-msvc@0.1.9: + resolution: {integrity: sha512-N29P0kmIBT6hnkNNFYzxn+o9ydT6OYZe9RFAi8dSxV1rNSul1FlxtuKWYe8w2sQpbQGYuGDEtpOwdeFtq2iFTg==} + engines: {node: '>=20 <23'} + cpu: [ia32] + os: [win32] + + node-addon-require-builtin-win32-x64-msvc@0.1.9: + resolution: {integrity: sha512-+i6N4A7m7txjbw8pDYESdWXiKO9EEVq0SzTzwPS0WZuV4F1MzQnaDJwJJnO7T6zAc0BvqHXBOkpharCrUjFhbQ==} + engines: {node: '>=20'} + cpu: [x64] + os: [win32] + + node-addon-require-builtin@0.1.9: + resolution: {integrity: sha512-BCxXCMZDs/VLLh64SwKNw/me7Qx1I7Be4qxWwYVRRm8tJoOB8fL7qg6CmXhSXeUqsKt72t8RaKCX4EN+5nocVg==} + engines: {node: '>=20'} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nypm@0.6.10: + resolution: {integrity: sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==} + engines: {node: '>=18'} + hasBin: true + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + openai@7.19.0: + resolution: {integrity: sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + undici: '>=5 <9' + ws: ^8.21.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + undici: + optional: true + ws: + optional: true + zod: + optional: true + + oxfmt@0.74.0: + resolution: {integrity: sha512-G4OP7FgwGYZR5ttRpCOOfJdyRsSyvtn34vSXnjDq6Oqed5Dufg5mB44mank+51nAdHOKuTJ0lj0mksY7L+N61A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@astrojs/compiler-rs': ^0.4.0 || ^0.5.0 + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + '@astrojs/compiler-rs': + optional: true + svelte: + optional: true + vite-plus: + optional: true + + oxlint-tsgolint@7.0.2004: + resolution: {integrity: sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ==} + hasBin: true + + oxlint@1.89.0: + resolution: {integrity: sha512-a25hkfo61okGPvJz432fRIPoxlKtNnOVy9w4rzrmGgNm4iVTPwpSQlmdqNWnp6p9QyyPVfAZRgnf5XvSBZFk/A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} + engines: {node: '>=18'} + + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + + proxy-agent-negotiate@1.1.0: + resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==} + engines: {node: '>= 20'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slice-ansi@9.0.1: + resolution: {integrity: sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==} + engines: {node: '>=22'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.16: + resolution: {integrity: sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==} + + tldts@7.4.16: + resolution: {integrity: sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} + engines: {node: ^22.14.0 || >=24.0.0} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultracite@8.1.0: + resolution: {integrity: sha512-YX14p9HzKjOZFi2N4zug+zM3757BZcRpxn8vnGKgO+0Vh3mnU9G1hZAgvSD9d1HAUyV1KSaCUAB8BfbqVXEnmQ==} + engines: {node: ^22.22.2 || >=24.15.0} + hasBin: true + peerDependencies: + '@biomejs/biome': ^2.6.2 + eslint: ^10.9.0 + oxfmt: '>=0.71.0 <1' + oxlint: ^1.86.0 + prettier: ^3.5.3 + stylelint: ^17.0.0 + peerDependenciesMeta: + '@biomejs/biome': + optional: true + eslint: + optional: true + oxfmt: + optional: true + oxlint: + optional: true + prettier: + optional: true + stylelint: + optional: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@8.12.2: + resolution: {integrity: sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==} + engines: {node: '>=22.19.0'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + vite-plus@1.4.0: + resolution: {integrity: sha512-bLyUKNFl0XjbHe20FpDM6OuknOb2E4ueoWKCW4n+wMO2+lQtA9tmAjW+Xs0Xe9rg2+tCgY3W13GOgDh2wjwjAQ==} + engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + playwright: '*' + peerDependenciesMeta: + '@vitest/browser-playwright': + optional: true + '@vitest/browser-webdriverio': + optional: true + playwright: + optional: true + + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@17.2.0: + resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==} + engines: {node: ^22.14.0 || >=24.0.0} + + which-command@0.1.0: + resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} + engines: {node: '>=22'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + wrap-ansi@10.0.2: + resolution: {integrity: sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==} + engines: {node: '>=20'} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + + yuku-core@0.19.0: + resolution: {integrity: sha512-kYK9e1XdfirNhkipzW0wmMcSWcVF/CWnuugodfVYRCUwvoBefweL9HzB96OiKEhsx78V5WbUxbJqlizMX869Qg==} + engines: {node: '>=14.18.0'} + + yuku-parser@0.19.0: + resolution: {integrity: sha512-XrBh9ZiQK5mJVRxZBAvD6LwRyAYrK3MFtH3+onyuxFL5hFD44EC3QOmIC1FjTj6H2MMwq46/NAI+NZxLZPZgxg==} + engines: {node: '>=14.18.0'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@anthropic-ai/sdk@0.129.0(zod@4.6.5)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.6.5 + + '@asamuzakjp/css-color@7.1.3': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@10.0.3': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + + '@aws-sdk/client-bedrock-runtime@3.1127.0': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-node': 3.972.84 + '@aws-sdk/eventstream-handler-node': 3.972.35 + '@aws-sdk/middleware-eventstream': 3.972.30 + '@aws-sdk/middleware-websocket': 3.972.54 + '@aws-sdk/token-providers': 3.1127.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.978.1': + dependencies: + '@aws-sdk/types': 3.974.6 + '@aws-sdk/xml-builder': 3.972.41 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.35.2 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.72': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.74': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.17': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-login': 3.972.79 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.79': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.84': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-ini': 3.973.17 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.72': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.16': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/token-providers': 3.1138.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.78': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.35': + dependencies: + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.30': + dependencies: + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.54': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.46': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.47': + dependencies: + '@aws-sdk/types': 3.974.6 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1127.0': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1138.0': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.6': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.41': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.7': {} + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@blazediff/core@1.10.0': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + + '@deepseek-ai/cordis-plugin-group@1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/cordis-plugin-loader': 1.0.6(@deepseek-ai/cordis@4.0.5)(node-addon-require-builtin@0.1.9) + + '@deepseek-ai/cordis-plugin-include@1.0.10(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/cordis-plugin-loader': 1.0.6(@deepseek-ai/cordis@4.0.5)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/cosmokit': 1.8.6 + js-yaml: 4.3.2 + + '@deepseek-ai/cordis-plugin-loader@1.0.6(@deepseek-ai/cordis@4.0.5)(node-addon-require-builtin@0.1.9)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/cosmokit': 1.8.6 + '@deepseek-ai/schemastery': 3.18.5 + optionalDependencies: + node-addon-require-builtin: 0.1.9 + + '@deepseek-ai/cordis@4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)': + dependencies: + '@deepseek-ai/cosmokit': 1.8.6 + '@standard-schema/spec': 1.1.0 + optionalDependencies: + '@deepseek-ai/cordis-plugin-include': 1.0.10(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/cordis-plugin-loader': 1.0.6(@deepseek-ai/cordis@4.0.5)(node-addon-require-builtin@0.1.9) + + '@deepseek-ai/cosmokit@1.8.6': {} + + '@deepseek-ai/dsh-app-boot@0.2.1-rc.5(@deepseek-ai/cordis-plugin-group@1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-home-paths@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-launch-environment@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/cordis-plugin-group': 1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/cordis-plugin-include': 1.0.10(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/cordis-plugin-loader': 1.0.6(@deepseek-ai/cordis@4.0.5)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/dsh-atomic-write': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-home-paths': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-launch-environment': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-package-manifest': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@eslint-community/regexpp': 4.12.2 + ajv: 8.20.0 + js-yaml: 4.3.2 + node-addon-require-builtin: 0.1.9 + resolve.exports: 2.0.3 + semver: 7.8.5 + + '@deepseek-ai/dsh-atomic-write@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/dsh-attachment@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-brand@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/dsh-brand': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + + '@deepseek-ai/dsh-authorization@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-credentials@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-llm@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/dsh-credentials': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-llm': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + + '@deepseek-ai/dsh-brand@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/dsh-client-store@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/dsh-client-ui-primitives@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/dsh-config-editor@0.2.1-rc.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-app-boot@0.2.1-rc.5(@deepseek-ai/cordis-plugin-group@1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-home-paths@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-launch-environment@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)))': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/cordis-plugin-include': 1.0.10(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/cordis-plugin-loader': 1.0.6(@deepseek-ai/cordis@4.0.5)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/dsh-app-boot': 0.2.1-rc.5(@deepseek-ai/cordis-plugin-group@1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-home-paths@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-launch-environment@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)) + '@deepseek-ai/dsh-atomic-write': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + js-yaml: 4.3.2 + yaml: 2.9.1 + + '@deepseek-ai/dsh-credentials@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/dsh-brand': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + + '@deepseek-ai/dsh-fs@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-brand@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-llm@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-sandbox@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-llm@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-session@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))))': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/dsh-brand': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-llm': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-sandbox': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-llm@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-session@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))) + + '@deepseek-ai/dsh-home-paths@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/dsh-launch-environment@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/dsh-llm-pi-ai@0.2.1-rc.5(da65ffcbbdf43427b0f0d7deae0abaf0)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/dsh-attachment': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-brand@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)) + '@deepseek-ai/dsh-authorization': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-credentials@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-llm@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)) + '@deepseek-ai/dsh-brand': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-credentials': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-fs': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-brand@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-llm@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-sandbox@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-llm@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-session@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)))) + '@deepseek-ai/dsh-launch-environment': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-llm': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-timeout': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-util-values': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/schemastery': 3.18.5 + '@earendil-works/pi-ai': 1.5.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5) + optionalDependencies: + '@deepseek-ai/cordis-plugin-loader': 1.0.6(@deepseek-ai/cordis@4.0.5)(node-addon-require-builtin@0.1.9) + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - kerberos + - supports-color + - undici + - utf-8-validate + - ws + - zod + + '@deepseek-ai/dsh-llm@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/dsh-brand': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-timeout': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-typert-protocol': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-util-crypto': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-util-values': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/schemastery': 3.18.5 + zod: 4.4.3 + + '@deepseek-ai/dsh-package-manifest@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/dsh-sandbox@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-llm@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-session@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)))': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/dsh-llm': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-session': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)) + '@deepseek-ai/dsh-util-values': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + + '@deepseek-ai/dsh-scope@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/dsh-session@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/dsh-brand': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-llm': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-scope': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-util-values': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + + '@deepseek-ai/dsh-settings@0.2.1-rc.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-app-boot@0.2.1-rc.5(@deepseek-ai/cordis-plugin-group@1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-home-paths@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-launch-environment@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)))(@deepseek-ai/dsh-brand@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-session@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)))(@deepseek-ai/schemastery@3.18.5)(node-addon-require-builtin@0.1.9)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/cordis-plugin-loader': 1.0.6(@deepseek-ai/cordis@4.0.5)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/cosmokit': 1.8.6 + '@deepseek-ai/dsh-brand': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/dsh-config-editor': 0.2.1-rc.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-app-boot@0.2.1-rc.5(@deepseek-ai/cordis-plugin-group@1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-home-paths@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-launch-environment@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5))) + '@deepseek-ai/dsh-session': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)) + '@deepseek-ai/dsh-util-values': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + '@deepseek-ai/schemastery': 3.18.5 + yaml: 2.9.1 + transitivePeerDependencies: + - '@deepseek-ai/cordis-plugin-include' + - '@deepseek-ai/dsh-app-boot' + - '@deepseek-ai/dsh-hmr' + - node-addon-require-builtin + + '@deepseek-ai/dsh-timeout@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/dsh-typert-protocol@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + '@deepseek-ai/dsh-brand': 0.2.1-rc.5(@deepseek-ai/cordis@4.0.5) + zod: 4.6.5 + + '@deepseek-ai/dsh-util-crypto@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/dsh-util-values@0.2.1-rc.5(@deepseek-ai/cordis@4.0.5)': + dependencies: + '@deepseek-ai/cordis': 4.0.5(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6) + + '@deepseek-ai/schemastery@3.18.5': + dependencies: + '@deepseek-ai/cosmokit': 1.8.6 + '@standard-schema/spec': 1.1.0 + + '@earendil-works/pi-ai@1.5.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5)': + dependencies: + '@anthropic-ai/sdk': 0.129.0(zod@4.6.5) + '@aws-sdk/client-bedrock-runtime': 3.1127.0 + '@earendil-works/pi-schema': 1.5.0 + '@earendil-works/pi-telemetry': 1.5.0 + '@google/genai': 2.21.0 + '@smithy/node-http-handler': 4.12.1 + http-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0 + openai: 7.19.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5) + partial-json: 0.1.7 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - kerberos + - supports-color + - undici + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-schema@1.5.0': {} + + '@earendil-works/pi-telemetry@1.5.0': {} + + '@eslint-community/regexpp@4.12.2': {} + + '@exodus/bytes@1.16.0': {} + + '@google/genai@2.21.0': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.6.6 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + + '@oxc-project/runtime@0.155.0': {} + + '@oxc-project/types@0.155.0': {} + + '@oxfmt/binding-android-arm-eabi@0.74.0': + optional: true + + '@oxfmt/binding-android-arm64@0.74.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.74.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.74.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.74.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.74.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.74.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.74.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.74.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.74.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.74.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.74.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.74.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.74.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.74.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.74.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.74.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.74.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.74.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2004': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2004': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2004': + optional: true + + '@oxlint/binding-android-arm-eabi@1.89.0': + optional: true + + '@oxlint/binding-android-arm64@1.89.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.89.0': + optional: true + + '@oxlint/binding-darwin-x64@1.89.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.89.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.89.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.89.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.89.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.89.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.89.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.89.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.89.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.89.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.89.0': + optional: true + + '@oxlint/plugins@1.89.0': {} + + '@polka/url@1.0.0-next.29': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@smithy/core@3.35.2': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.8.0': + dependencies: + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.12.1': + dependencies: + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.4': + dependencies: + '@smithy/core': 3.35.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/types@4.19.0': + dependencies: + tslib: 2.8.1 + + '@stablelib/base64@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.6.6': + dependencies: + undici-types: 8.9.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/retry@0.12.0': {} + + '@types/semver@7.8.0': {} + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/browser-preview@5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.5)': + dependencies: + '@testing-library/dom': 10.4.2 + '@testing-library/user-event': 14.6.7(@testing-library/dom@10.4.2) + '@vitest/browser': 5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.5) + vitest: 5.0.5(@types/node@26.6.6)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(jsdom@30.1.3) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.5)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1)) + '@vitest/ui': 5.0.5(vitest@5.0.5) + '@vitest/utils': 5.0.5 + magic-string: 1.4.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@26.6.6)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(jsdom@30.1.3) + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-report': 1.0.2 + ast-v8-to-istanbul: 1.0.7 + magicast: 0.5.5 + obug: 3.0.0 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@26.6.6)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(jsdom@30.1.3) + optionalDependencies: + '@vitest/browser': 5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.5) + + '@vitest/istanbul-lib-coverage@1.0.2': {} + + '@vitest/istanbul-lib-report@1.0.2': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.2 + + '@vitest/mocker@5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: '@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1)' + + '@vitest/pretty-format@5.0.5': + dependencies: + tinyrainbow: 3.2.0 + + '@vitest/snapshot@5.0.5': + dependencies: + '@vitest/pretty-format': 5.0.5 + '@vitest/utils': 5.0.5 + magic-string: 1.4.3 + pathe: 2.0.3 + + '@vitest/spy@5.0.5': {} + + '@vitest/ui@5.0.5(vitest@5.0.5)': + dependencies: + '@vitest/utils': 5.0.5 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@26.6.6)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(jsdom@30.1.3) + + '@vitest/utils@5.0.5': + dependencies: + '@vitest/pretty-format': 5.0.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + + '@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1)': + dependencies: + '@oxc-project/runtime': 0.155.0 + '@oxc-project/types': 0.155.0 + lightningcss: 1.33.0 + postcss: 8.5.30 + yuku-parser: 0.19.0 + optionalDependencies: + '@types/node': 26.6.6 + '@voidzero-dev/vite-plus-darwin-arm64': 1.4.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.4.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.4.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.4.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.4.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.4.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.4.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.4.0 + fsevents: 2.3.3 + typescript: 7.0.2 + yaml: 2.9.1 + + '@voidzero-dev/vite-plus-darwin-arm64@1.4.0': + optional: true + + '@voidzero-dev/vite-plus-darwin-x64@1.4.0': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-gnu@1.4.0': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-musl@1.4.0': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-gnu@1.4.0': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-musl@1.4.0': + optional: true + + '@voidzero-dev/vite-plus-win32-arm64-msvc@1.4.0': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@1.4.0': + optional: true + + '@yuku-core/binding-android-arm64@0.19.0': + optional: true + + '@yuku-core/binding-darwin-arm64@0.19.0': + optional: true + + '@yuku-core/binding-darwin-x64@0.19.0': + optional: true + + '@yuku-core/binding-freebsd-x64@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm-gnu@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm-musl@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm64-gnu@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm64-musl@0.19.0': + optional: true + + '@yuku-core/binding-linux-x64-gnu@0.19.0': + optional: true + + '@yuku-core/binding-linux-x64-musl@0.19.0': + optional: true + + '@yuku-core/binding-win32-arm64@0.19.0': + optional: true + + '@yuku-core/binding-win32-x64@0.19.0': + optional: true + + '@yuku-toolchain/types@0.19.0': {} + + acorn@8.18.0: {} + + agent-base@7.1.4: {} + + agent-base@9.0.0: {} + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.4.0: {} + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + base64-js@1.5.1: {} + + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + + bignumber.js@9.3.1: {} + + bowser@2.14.1: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer-equal-constant-time@1.0.1: {} + + chai@6.3.0: {} + + citty@0.2.2: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@6.1.1: + dependencies: + slice-ansi: 9.0.1 + string-width: 8.3.0 + + commander@15.0.0: {} + + confbox@0.1.8: {} + + convert-source-map@2.0.0: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: {} + + data-urls@8.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0 + transitivePeerDependencies: + - '@noble/hashes' + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deepmerge@4.3.1: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + empathic@2.1.0: {} + + entities@8.1.0: {} + + environment@1.1.0: {} + + es-module-lexer@2.3.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + execa@10.0.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.1 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + which-command: 0.1.0 + yoctocolors: 2.2.0 + + expect-type@1.4.0: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-sha256@1.3.0: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.9: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + fflate@0.8.3: {} + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-workspaces@0.3.1: + dependencies: + fast-glob: 3.3.3 + pkg-types: 1.3.1 + yaml: 2.9.1 + + flatted@3.4.4: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fsevents@2.3.3: + optional: true + + gaxios@7.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.1 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + get-east-asian-width@1.7.0: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + google-auth-library@10.9.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.1 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + html-encoding-sniffer@7.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + + human-signals@8.0.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.7.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsdom@30.1.3: + dependencies: + '@asamuzakjp/css-color': 7.1.3 + '@asamuzakjp/dom-selector': 10.0.3 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + '@exodus/bytes': 1.16.0 + css-tree: 3.2.1 + data-urls: 8.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.0.1 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.12.2 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + + json-schema-traverse@1.0.0: {} + + jsonc-parser@3.3.1: {} + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + log-update@8.0.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 9.0.1 + string-width: 8.3.0 + strip-ansi: 7.2.0 + wrap-ansi: 10.0.2 + + long@5.3.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@11.5.3: {} + + lz-string@1.5.0: {} + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + mdn-data@2.27.1: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mimic-function@5.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + node-addon-native-custom-loader@0.1.9: {} + + node-addon-require-builtin-darwin-arm64@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-darwin-x64@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-linux-arm64-gnu@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-linux-arm64-musl@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-linux-x64-gnu@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-linux-x64-musl@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-win32-arm64-msvc@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-win32-ia32-msvc@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-win32-x64-msvc@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optionalDependencies: + node-addon-require-builtin-darwin-arm64: 0.1.9 + node-addon-require-builtin-darwin-x64: 0.1.9 + node-addon-require-builtin-linux-arm64-gnu: 0.1.9 + node-addon-require-builtin-linux-arm64-musl: 0.1.9 + node-addon-require-builtin-linux-x64-gnu: 0.1.9 + node-addon-require-builtin-linux-x64-musl: 0.1.9 + node-addon-require-builtin-win32-arm64-msvc: 0.1.9 + node-addon-require-builtin-win32-ia32-msvc: 0.1.9 + node-addon-require-builtin-win32-x64-msvc: 0.1.9 + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nypm@0.6.10: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.3.1 + + obug@3.0.0: {} + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + openai@7.19.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.84 + '@smithy/signature-v4': 5.7.4 + undici: 8.12.2 + ws: 8.22.0 + zod: 4.6.5 + + oxfmt@0.74.0(vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1)): + dependencies: + tinypool: 2.2.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.74.0 + '@oxfmt/binding-android-arm64': 0.74.0 + '@oxfmt/binding-darwin-arm64': 0.74.0 + '@oxfmt/binding-darwin-x64': 0.74.0 + '@oxfmt/binding-freebsd-x64': 0.74.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.74.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.74.0 + '@oxfmt/binding-linux-arm64-gnu': 0.74.0 + '@oxfmt/binding-linux-arm64-musl': 0.74.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.74.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.74.0 + '@oxfmt/binding-linux-riscv64-musl': 0.74.0 + '@oxfmt/binding-linux-s390x-gnu': 0.74.0 + '@oxfmt/binding-linux-x64-gnu': 0.74.0 + '@oxfmt/binding-linux-x64-musl': 0.74.0 + '@oxfmt/binding-openharmony-arm64': 0.74.0 + '@oxfmt/binding-win32-arm64-msvc': 0.74.0 + '@oxfmt/binding-win32-ia32-msvc': 0.74.0 + '@oxfmt/binding-win32-x64-msvc': 0.74.0 + vite-plus: 1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1) + + oxlint-tsgolint@7.0.2004: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 7.0.2004 + '@oxlint-tsgolint/darwin-x64': 7.0.2004 + '@oxlint-tsgolint/linux-arm64': 7.0.2004 + '@oxlint-tsgolint/linux-x64': 7.0.2004 + '@oxlint-tsgolint/win32-arm64': 7.0.2004 + '@oxlint-tsgolint/win32-x64': 7.0.2004 + + oxlint@1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.89.0 + '@oxlint/binding-android-arm64': 1.89.0 + '@oxlint/binding-darwin-arm64': 1.89.0 + '@oxlint/binding-darwin-x64': 1.89.0 + '@oxlint/binding-freebsd-x64': 1.89.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.89.0 + '@oxlint/binding-linux-arm-musleabihf': 1.89.0 + '@oxlint/binding-linux-arm64-gnu': 1.89.0 + '@oxlint/binding-linux-arm64-musl': 1.89.0 + '@oxlint/binding-linux-ppc64-gnu': 1.89.0 + '@oxlint/binding-linux-riscv64-gnu': 1.89.0 + '@oxlint/binding-linux-riscv64-musl': 1.89.0 + '@oxlint/binding-linux-s390x-gnu': 1.89.0 + '@oxlint/binding-linux-x64-gnu': 1.89.0 + '@oxlint/binding-linux-x64-musl': 1.89.0 + '@oxlint/binding-openharmony-arm64': 1.89.0 + '@oxlint/binding-win32-arm64-msvc': 1.89.0 + '@oxlint/binding-win32-ia32-msvc': 1.89.0 + '@oxlint/binding-win32-x64-msvc': 1.89.0 + oxlint-tsgolint: 7.0.2004 + vite-plus: 1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1) + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + parse-ms@4.0.0: {} + + parse5@8.0.1: + dependencies: + entities: 8.1.0 + + partial-json@0.1.7: {} + + path-key@4.0.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pngjs@7.0.0: {} + + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-ms@9.3.1: + dependencies: + parse-ms: 4.0.0 + + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 26.6.6 + long: 5.3.2 + + proxy-agent-negotiate@1.1.0: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@17.0.2: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + require-from-string@2.0.2: {} + + resolve.exports@2.0.3: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.13.1: {} + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.2.1: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@7.8.5: {} + + signal-exit@4.1.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + slice-ansi@9.0.1: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + source-map-js@1.2.1: {} + + source-map-js@1.2.2: {} + + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + std-env@4.3.0: {} + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + strip-final-newline@4.0.0: {} + + tinybench@6.2.2: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@2.2.0: {} + + tinyrainbow@3.2.0: {} + + tldts-core@7.4.16: {} + + tldts@7.4.16: + dependencies: + tldts-core: 7.4.16 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + totalist@3.0.1: {} + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.16 + + tr46@7.0.0: + dependencies: + punycode: 2.3.1 + + ts-algebra@2.0.0: {} + + tslib@2.8.1: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + ufo@1.6.4: {} + + ultracite@8.1.0(oxfmt@0.74.0(vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1)))(oxlint@1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1))): + dependencies: + '@clack/prompts': 1.8.1 + cli-truncate: 6.1.1 + commander: 15.0.0 + deepmerge: 4.3.1 + empathic: 2.1.0 + execa: 10.0.1 + fast-glob: 3.3.3 + find-workspaces: 0.3.1 + jsonc-parser: 3.3.1 + log-update: 8.0.0 + magicast: 0.5.5 + npm-run-path: 6.0.0 + nypm: 0.6.10 + resolve.exports: 2.0.3 + semver: 7.8.5 + string-width: 8.3.0 + which-command: 0.1.0 + yaml: 2.9.1 + zod: 4.6.5 + optionalDependencies: + oxfmt: 0.74.0(vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1)) + oxlint: 1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1)) + + undici-types@8.9.0: {} + + undici@8.12.2: {} + + unicorn-magic@0.3.0: {} + + vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1): + dependencies: + '@oxc-project/runtime': 0.155.0 + '@oxc-project/types': 0.155.0 + '@oxlint/plugins': 1.89.0 + '@vitest/browser': 5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/browser-preview': 5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/mocker': 5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1)) + '@vitest/pretty-format': 5.0.5 + '@vitest/snapshot': 5.0.5 + '@vitest/spy': 5.0.5 + '@vitest/utils': 5.0.5 + oxfmt: 0.74.0(vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1)) + oxlint: 1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@types/node@26.6.6)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.3)(typescript@7.0.2)(yaml@2.9.1)) + oxlint-tsgolint: 7.0.2004 + vite: '@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1)' + vitest: 5.0.5(@types/node@26.6.6)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(jsdom@30.1.3) + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 1.4.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.4.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.4.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.4.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.4.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.4.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.4.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.4.0 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@astrojs/compiler-rs' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@typescript/native-preview' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - '@volar/typescript' + - '@vue/language-core' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - yaml + + vitest@5.0.5(@types/node@26.6.6)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(jsdom@30.1.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: '@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1)' + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 26.6.6 + '@vitest/browser-preview': 5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/coverage-v8': 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5) + '@vitest/ui': 5.0.5(vitest@5.0.5) + jsdom: 30.1.3 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@17.2.0: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which-command@0.1.0: {} + + why-is-node-running@3.2.1: {} + + wrap-ansi@10.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.3.0 + + ws@8.22.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yaml@2.9.1: {} + + yoctocolors@2.2.0: {} + + yuku-core@0.19.0: + dependencies: + '@yuku-toolchain/types': 0.19.0 + optionalDependencies: + '@yuku-core/binding-android-arm64': 0.19.0 + '@yuku-core/binding-darwin-arm64': 0.19.0 + '@yuku-core/binding-darwin-x64': 0.19.0 + '@yuku-core/binding-freebsd-x64': 0.19.0 + '@yuku-core/binding-linux-arm-gnu': 0.19.0 + '@yuku-core/binding-linux-arm-musl': 0.19.0 + '@yuku-core/binding-linux-arm64-gnu': 0.19.0 + '@yuku-core/binding-linux-arm64-musl': 0.19.0 + '@yuku-core/binding-linux-x64-gnu': 0.19.0 + '@yuku-core/binding-linux-x64-musl': 0.19.0 + '@yuku-core/binding-win32-arm64': 0.19.0 + '@yuku-core/binding-win32-x64': 0.19.0 + + yuku-parser@0.19.0: + dependencies: + '@yuku-toolchain/types': 0.19.0 + yuku-core: 0.19.0 + + zod@4.4.3: {} + + zod@4.6.5: {} diff --git a/cordis.patch.yml b/cordis.patch.yml index 75beab27..a8ed730e 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -20,8 +20,12 @@ # model discovery; off leaves both raw (default true). # injectCoreTools: boolean auto-inject read & bash tool schemas on free models (default true). # usageEnabled: boolean enable host-side OpenCode Go usage querying (default true). -# showUsagePrice: boolean show session spend + active model rate in the meter (default true). # keySource: string which credential source wins: auto | request | configured (default 'auto'). +# readOpencodeStore: boolean read the credential an OpenCode installation already holds, as the LAST +# resort after every declared one (default false). +# endpoint: string endpoint family: auto | modern | legacy (default 'auto', an alias of +# modern). modern = the per-protocol inference family; legacy = one base +# per plane. # # Config-only overrides — the card deliberately hides these, because each is a # literal, a marker, or a reference whose default fits every documented setup. @@ -33,12 +37,19 @@ # userAgent: string optional custom User-Agent override (default empty = use OpenCode CLI UA). # originClient: string x-opencode-client value (default 'cli'). # sessionIdEnv: string env var carrying a caller-supplied session id (default 'OPENCODE_SESSION_ID'). -# freeModelMarker: string model-id substring getting the core-tool fallback ('*' = all, '' = none; default 'free'). +# freeModelMarker: string model-id substring getting the core-tool fallback ('*' = all; a blank +# value inherits the default 'free'; models the catalog marks free always +# qualify). # gatewayUrls: [string] URL substrings marking OpenCode gateway traffic (default both # families: ['opencode.ai/zen', 'opencode.ai/inference']). # usageBaseURL: string OpenCode Go gateway base URL (default 'https://opencode.ai/zen/go/v1'). -# debug: true|false log every streamed call that receives the header. -# debugFile: path optional append target for stream debug JSONL. +# credentialOrg: string org id (wrk_...) sent with an account credential that came from elsewhere. +# maxContextWindow: number optional ceiling clamping model context windows (e.g. 131072). +# modelOverrides: object per-model overrides for contextWindow, maxTokens and metadata (default {}). +# modelRoutes: object per-model wire routing: { : { api, baseURL } } (default {}). +# debug: true|false log every streamed call that receives the header to the console. +# debugFile: path optional JSONL file for the request and stream debug entries; naming +# it writes the file whether or not debug is on. - insert: - id: dsh-opencode-patch name: "dsh-opencode-patch" @@ -58,5 +69,4 @@ injectCoreTools: true enrichModels: true usageEnabled: true - showUsagePrice: true debug: false diff --git a/docs/README.md b/docs/README.md index d8e2d6d4..f14b8b0c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Documentation -One document per subject. Everything here is engineering material and written in English, except the meter's guide, which is user-facing and therefore exists in both languages, the same rule the READMEs follow. +One document per subject. Everything here is engineering material and written in English, except the meter's guide and the plugin page, which are user-facing and therefore exist in both languages, the same rule the READMEs follow. | Document | Subject | Language | | :-- | :-- | :-- | @@ -9,8 +9,15 @@ One document per subject. Everything here is engineering material and written in | [`protocol-routing-and-merge.md`](./protocol-routing-and-merge.md) | the routing **implementation**: why routing exists, how the route is chosen, the catalog merge, and where the code lives | English | | [`engineering-notes.md`](./engineering-notes.md) | vendor findings, and the rules that came out of real failures | English | | [`credentials.md`](./credentials.md) | the credential chain: the deployment's own reference, both OAuth logins, refresh semantics, and the zero-configuration guarantee it must not break | English | +| [`testing.md`](./testing.md) | the test system: the layers, the fixture harness, the scripts' pure halves | English | +| [`upstream-findings.md`](./upstream-findings.md) | what upstream OpenCode does that this plugin tracks, what the host would have to change, and what was verified against upstream source | English | +| [`opencode-1.19-api.md`](./opencode-1.19-api.md) | the API the current OpenCode CLI talks to instead of this plugin's, and what a migration would change | English | +| [`opencode-parity-plan.md`](./opencode-parity-plan.md) | the parity plan with the OpenCode CLI: protocol, credential, session and catalog fidelity | English | +| [`improvement-plan.md`](./improvement-plan.md) | the plan of record: what the audit found, what the host supports, and the phased refactor, with status | English | | [`quota-meter.md`](./quota-meter.md) | the composer meter, state by state: what it shows, what each row answers, and where the numbers come from | English | | [`quota-meter.zh-CN.md`](./quota-meter.zh-CN.md) | 同一份说明的中文版 | 中文 | +| [`detail.md`](./detail.md) | the plugin page the host renders for this package (`meta.detail`) | English | +| [`detail.zh-CN.md`](./detail.zh-CN.md) | 插件页的中文版 | 中文 | **Specification versus implementation is the one split worth knowing.** `redirect-planes.md` says what the code **must** do; `protocol-routing-and-merge.md` says what it **does**; the guards in the former are what holds the two together. When they disagree, one of them is wrong. That is the point of keeping both. diff --git a/docs/credentials.md b/docs/credentials.md index 63e09eeb..e0f7b99d 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -278,7 +278,7 @@ The two modules existed and were tested before anything called them. The wiring 2. `src/go-discovery.ts` — one more entry in the step map `firstResolved` takes, and **last in every policy**, because the user's own declaration wins: `opencodeStore: () => readFromStore(ctx, plane)`. It resolves the path with `opencodeDatabasePath({ channel: process.env.OPENCODE_CHANNEL, env: process.env, home: os.homedir() }, os.tmpdir())`, reads with `readOpencodeCredentials`, picks the entry whose `integrationId` matches the plane, and answers `undefined` for everything else — a missing file included. 3. `src/settings-fields.ts` — one `CARD_FIELDS` control, which is what makes the switch reachable. 4. **Which of the two mechanisms to use, now that both have been read.** `discoverGoConfig` walks `ctx.loader.entries()` and reads `entry.options.config`, but that is the PROVIDER REGISTRY — the rows that describe `opencode-go` and `opencode` — not this plugin's own configuration. This plugin's own config arrives as a VALUE: `fetch-patch.ts` receives `config: ResolvedPluginConfig` as a parameter, and `lifecycle.ts` is what passes it. So the new step should take the flag as an argument threaded from `lifecycle` rather than dig for it in the loader, and that means `resolveGoApiKey` needs one more parameter (it currently takes `ctx`, `targetProvider` and `policy`). -5. The config travels as VALUES rather than as a config object: `lifecycle.ts` resolves it with `resolveConfig` and passes fields down. `usage.ts:285` calls `resolveGoApiKey(...)` for the meter and `fetch-patch.ts:253` reads `config.keySource` directly for the headers, which is the precedent for how a field arrives. **The model-call path is now traced, and it is `fetch-patch.ts`**: the interceptor's Authorization fallback is what fills the `Bearer unused` sentinel the internal routes carry, so that is where a request's own key is resolved. The other one is `google-bridge.ts`'s `resolveApiKey`, the per-request resolver for the `opencode-google` route specifically — `responses-provider.ts` calls neither `resolveRoutedKey` nor `resolveApiKey`, as the grep said; it hands the bridge its inputs, and the flag travels that way. Both consult the store only after the captured key and the declared environment, so they agree with the meter. +5. The config travels as VALUES rather than as a config object: `lifecycle.ts` resolves it with `resolveConfig` and passes fields down. `usage.ts:285` calls `resolveGoApiKey(...)` for the meter and `fetch-patch.ts:253` reads `config.keySource` directly for the headers, which is the precedent for how a field arrives. **The model-call path is now traced, and it is `fetch-patch.ts`**: the interceptor's Authorization fallback is what fills the `Bearer unused` sentinel the internal routes carry, so that is where a request's own key is resolved. The other one is `google-bridge.ts`'s `resolveApiKey`, the per-request resolver for the `opencode-google` route specifically — `responses-provider.ts` calls neither `resolveRoutedKey` (since deleted in 1.x: it was exported and never called from `src/`) nor `resolveApiKey`, as the grep said; it hands the bridge its inputs, and the flag travels that way. Both consult the store only after the captured key and the declared environment, so they agree with the meter. 6. Tests: the step answers a key from a store this suite builds, answers `undefined` with the switch off, and still loses to a declared credential and to a captured one; the fetch patch fills its Authorization header from the store, per plane; and the bridge carries a stored login over the store. **One bug the wiring found, worth recording because the login reader masked it.** `google-bridge.ts` derived the plane with `String(route).startsWith("opencode-go")`, and `"opencode-google"` starts with `"opencode-go"` — so the Zen route was read as the Go one. `loginTokenReader` hid that by falling back to the other plane; an integration-id lookup does not, so the derivation is now an exact route comparison. diff --git a/docs/deep-dive.md b/docs/deep-dive.md index 9affe5c1..aea2fb4e 100644 --- a/docs/deep-dive.md +++ b/docs/deep-dive.md @@ -168,9 +168,9 @@ How does this compare to Duskriver's [`dsh-opencode-go`](https://www.npmjs.com/p OpenCode's gateway `GET …/models` endpoints frequently return a truncated subset: no display names, context windows, max tokens or input modalities. The plugin ships a **stale-while-revalidate** catalog for both planes: -1. Dual bundled shims (zero latency, offline): `OPENCODE_GO_CATALOG` carries every active Go subscription model with per-million-token rates, so session pricing works before the first refresh; `OPENCODE_ZEN_CATALOG` carries the flagships (`claude-sonnet-4-5`, `claude-opus-4-7`, `gpt-5.4`, `gemini-3.8-flash`, `qwen3.8-max`, `kimi-k3`). The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file, and CI fails when the shim is stale. Retired models are excluded so a failed refresh can never resurrect a row the gateway no longer serves: the three Zen-route Muse Spark 1.2 ids are suppressed, while the paid Go 1.2 contributor entry stays (the CLI still lists it). Startup is instant: no cold-start delay, blocking network calls, or airplane-mode failures. +1. Dual bundled shims (zero latency, offline): `OPENCODE_GO_CATALOG` carries every active Go subscription model with per-million-token rates, so session pricing works before the first refresh; `OPENCODE_ZEN_CATALOG` carries the flagships (`claude-sonnet-4-5`, `claude-opus-4-7`, `gpt-5.4`, `gemini-3.8-flash`, `qwen3.8-max`, `kimi-k3`). The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file, and CI checks the shim for drift: a pull request that changes the generator (or what it loads) fails on a stale shim; any other pull request, and a push to main, warns, and the nightly run proposes the refresh. Retired models are excluded so a failed refresh can never resurrect a row the gateway no longer serves: a retired model is a row the registry marks `status: "deprecated"`, the generator drops it per plane, and, like the counts, the ids are deliberately not restated here. Startup is instant: no cold-start delay, blocking network calls, or airplane-mode failures. 2. Background revalidation: both catalogs revalidate against [`https://models.opencode.ai/api.json`](https://models.opencode.ai/api.json) — the OpenCode catalog, which today serves the byte-identical object as `models.dev/api.json` — every **60 minutes** (the OpenCode CLI's canonical cycle), merging new models, deprecations and updated limits. Errors degrade gracefully and retain the active catalog. -3. Gateway models-endpoint enrichment: `patchFetch` intercepts `GET …/models` on OpenCode routes and merges the live Go or Zen catalog: human-friendly names (`DeepSeek V4.1 Flash`, `Qwen3.8 Flash`, `Grok 4.7`, `MiMo V2.6 Pro`), verified context windows (up to 1,000,000+ tokens) and max output tokens (up to 384,000), correct input modalities (`text`, `image`), with retired Muse Spark 1.2 rows omitted. +3. Gateway models-endpoint enrichment: `patchFetch` intercepts `GET …/models` on OpenCode routes and merges the live Go or Zen catalog: human-friendly names (`DeepSeek V4.1 Flash`, `Qwen3.8 Flash`, `Grok 4.7`, `MiMo V2.6 Pro`), the catalog's context windows and max output tokens, correct input modalities (`text`, `image`), with registry-deprecated rows omitted. 4. Settings “Fetch Available Models” decoration: DSH asks the route's own adapter first, and for an installed `opencode` route `llm-pi-ai` answers from its packaged catalog without calling the gateway. The plugin therefore decorates the hosted discovery result: adapter rows and order are preserved, their metadata enriched from the catalog, provider-retired rows removed, and **models whose protocol DSH cannot speak dropped** (offering one could only fail). It **does not append** catalog rows the adapter did not report: an earlier revision did, and that is how a deployment that had declared four models saw eighty-six in Settings. This is candidate metadata for the settings surface; it never rewrites saved route configuration. Widening a deployment's list is a separate, explicit action — the card's **Check catalog** row, which shows what the catalog offers and declares it only when asked. 5. Native model discovery registration: on the host runtime the plugin also registers with `ctx.llm.registerModelDiscovery` for `opencode-go` and `opencode`, which is what a _draft_ route's “discover” call answers from — not the picker, which reads the declared list. The gateway-listing enrichment, this registration, the discovery decoration and the model-info decoration all sit behind the **Enrich model metadata from models.dev** switch; the adapter decoration and the Responses-route hiding do not. @@ -186,7 +186,7 @@ Behind the **Enable Composer Usage Meter** switch (on by default): ### 9. Key resolution per routed model & overage behavior -Different models may route to different accounts (a corporate Go subscription alongside a personal Zen key). `resolveRoutedKey(ctx, provider)` inspects the loaded Cordis rows for the `apiKeyEnv` / literal `apiKey` assigned to each route, and **the route decides which account is queried: not the shape of the key**. The prefix test that used to sit here was removed once it was measured: OpenCode issues `oc_sk_…` for Go credentials too, so the string says nothing about the route in use. The meter queries accordingly: +Different models may route to different accounts (a corporate Go subscription alongside a personal Zen key). `discoverGoConfig(ctx, provider)` reads the loaded Cordis row for the `apiKeyEnv` / literal `apiKey` assigned to the route, `resolveGoApiKey` resolves it under this plugin's own `keySource` setting, and **the route decides which account is queried: not the shape of the key**. The prefix test that used to sit here was removed once it was measured: OpenCode issues `oc_sk_…` for Go credentials too, so the string says nothing about the route in use. The meter queries accordingly: - OpenCode Go (`opencode-go`): fixed subscription quotas across three windows (5-hour rolling, weekly, monthly %). At 100% the gateway answers `GoUsageLimitError` (HTTP 402/429). Server-side overflow into Zen balance works only if that Go account has _Use balance_ enabled ([opencode.ai/workspace/go](https://opencode.ai/workspace/go)): a Zen key on a _separate_ account is never debited automatically. - OpenCode Zen (`opencode`): per-token pay-as-you-go against the account balance; no rolling windows. At $0.00 the gateway returns `HTTP 402 Insufficient account funds`: top up via the Console link in the popover. diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 5dd94e11..a26d0a09 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -92,9 +92,9 @@ All three were listed on the same route, which declares `api: openai-completions So the fix belongs here rather than in anyone's profile: the routes for the other protocols have to come from the plugin, the way the completions route already does. Until they do, a model that names an SDK other than completions is offered on a route that cannot serve it, and the answer is the `400` above. -**And the deeper half: a model the catalog does not carry cannot be routed at all.** Measured: `gemini-3.8-flash` and `claude-sonnet-4-6` are in neither shipped catalog, so `findModelSpecOn` finds no entry, `provider_npm` is `undefined`, and `undefined` is the DEFAULT — the completions shape. That default is right for a model that omits its SDK and wrong for one the catalog has never heard of, and the two are indistinguishable at that point. A model listed by hand on a completions route therefore stays there whatever protocol it actually speaks, and the gateway answers `400` — which is the whole of the reported symptom. +**And the deeper half: a model the plane's catalog does not carry cannot be routed at all.** Measured on the Go plane, where the table above was taken: neither `gemini-3.8-flash` nor `claude-sonnet-4-6` has a row in the Go catalog (both are Zen rows), so `findModelSpecOn("go", …)` finds no entry, `provider_npm` is `undefined`, and `undefined` is the DEFAULT — the completions shape. That default is right for a model that omits its SDK and wrong for one the catalog has never heard of, and the two are indistinguishable at that point. A model listed by hand on a completions route therefore stays there whatever protocol it actually speaks, and the gateway answers `400` — which is the whole of the reported symptom. -`catalog-data.ts` carries `provider_npm` only for models that override the default, so absence is meaningful for the 82 it ships and meaningless for anything else. A hand-listed model needs its protocol from somewhere: the catalog, a route that declares it, or a rule that reads it off the id. +`catalog-data.ts` carries `provider_npm` only for models that override the default, so absence is meaningful for the models it ships and meaningless for anything else. A hand-listed model needs its protocol from somewhere: the catalog, a route that declares it, or a rule that reads it off the id. **And the other half of I1: it is still OFFERED.** `models-discovery.ts` filters a route's listing with `isServableModel`, which asks the catalog for the model's SDK and treats a missing answer as servable — correctly, because absence means the completions default. For a model the catalog has never heard of, absence means the same thing by accident, so a hand-listed model is offered on a route that cannot serve it. The routing fix above moves it when it is chosen; this is the half that would stop it being offered at all, and the two belong together: I1 says a model that cannot work is not offered, and today that clause holds only for models the catalog describes. @@ -228,7 +228,7 @@ Worth keeping separate because the conclusions differ: the first is fixed by not ## Every count in the documentation, checked against the data -Two were missed while chasing the catalog's changes — Go's total and its no-SDK row — so the sweep was finished properly: every number stated in any document, compared against `catalog-data.ts`. The facts are Zen 82 with 25 no-SDK, 30 openai, 18 anthropic, 8 google and 1 mistral, and Go 32 with 20 no-SDK, and every live count in the docs says exactly that. +Two were missed while chasing the catalog's changes — Go's total and its no-SDK row — so the sweep was finished properly: every number stated in any document, compared against `catalog-data.ts`, and every live count in the docs agreed with the shim of that day. The counts are deliberately not restated here, because the shim has been regenerated since and a number in this paragraph is one no test reads: the current ones are the dated tables in [`redirect-planes.md`](./redirect-planes.md) §1 and §2, which `test/catalog.test.ts` reads back against the shipped shim. Two numbers that look stale are not, and they are worth naming so the next sweep does not "fix" them: `0 of 31` appears once, inside this file, as a quotation of the mistake; and `16 of 17` is a recorded test result from the round that narrowed the header test, not a count of anything current. @@ -332,7 +332,7 @@ So it is a known gap with a known shape rather than an unknown one: a number in ## The count gap is closed, and nothing else restates one -With the prose totals asserted, every count in this repository is now either parsed by a test or generated by the script that owns it. Swept for the rest: no other document states a catalog number, and `deep-dive.md` avoids the problem the way it should — _"The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file, and CI fails when the shim is stale."_ That is the better pattern, and it is why that file was never wrong about a count while four others were. +With the prose totals asserted, every count in this repository is now either parsed by a test or generated by the script that owns it; a test total the README once stated went the same way, dropped rather than left for nothing to check. Swept for the rest: no other document states a catalog number, and `deep-dive.md` avoids the problem the way it should — _"The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file …"_ That is the better pattern, and it is why that file was never wrong about a count while four others were. So the class has an ending rather than a fifth instance: numbers live where a test reaches them, or they are not written down. @@ -542,6 +542,18 @@ What does NOT work, stated plainly, is the other half. `prepareCall` reads the r The fix is therefore not a mount: it is a route whose adapter the plugin itself owns and whose `models` list carries the catalog's `reasoningEfforts` — the same `models` list the internal routes already use, which works precisely because `configured.length > 0` takes the `entries` from it directly and tolerates ids the installed catalog has never heard of. That means giving the model an SDK so `internalRouteFor` can redirect it, or listing it in the deployment's profile, and neither is something to invent without a measurement of which endpoint the model is actually served on. +## Replacing another plugin's adapter registration is not an API, so the plugin does not try + +`src/adapter-replacement.ts` took a provider route over from the adapter that already served it: it released the llm registry's map entry for a route pi-ai held, registered our adapter through `registerAdapter`, and put the original entry back on disposal. Nothing in `src/` ever imported it, and P1b deleted it with its test. + +It could not have been wired, and the reason is read from the host's llm runtime (`packages/llm/llm/src/index.ts`) rather than assumed: + +1. **`registerAdapter` is all-or-nothing against a private map.** `prepareRoutes` throws `DUPLICATE_ADAPTER` for any route another registration holds (`:440-444`), so the only way in was to delete that registration's entry first. +2. **Only the owner moves its routes.** A registration's routes change through its own `handle.replace`, the method `registerAdapter` returns on its handle (`:421-428`). Nothing else is an API for it. +3. **The owner's disposer deletes what it still believes it holds.** On dispose, the registration deletes every route in its `owned` set and emits `llm/adapters-updated` (`registerAdapter`'s disposer, `:411-416`). An entry deleted from outside leaves pi-ai's registration believing it still owns the route, so its next dispose or restart deletes OUR entry — the plane then answers `NO_ADAPTER` — and its next `replace` registers over us. This failure mode is read from the host's disposer and has not been run. S0's handback failed for a different reason: the prototype released its registration while its own config listener was still live (see the plan's spike record), which is why P-R1 calls its handle and then makes its listener inert in one synchronous section, with no await between them, and restarts pi-ai only after both (the plan's Teardown). + +What the plugin does instead (D13, landing in P-R1 as `src/host/route-claim.ts`): keep pi-ai from claiming `opencode` / `opencode-go` in the first place, through the config waterfall — a prepended `internal/config` listener strips the two plane keys from a copy of the config pi-ai reads — and register its own adapter under the same ids through the ordinary `registerAdapter`. On teardown it releases its own handle and restarts pi-ai, which hands the routes back. It never deletes another plugin's registration; `test/architecture.test.ts` fails the build on a direct `adapters.delete` / `set` / `clear` call (optional chaining included) anywhere in `lifecycle.ts`'s static import graph. + ## A placeholder in a string has no spelling this linter accepts `check.ts` asserts the refresh push carries the PAT by looking for the token URL as a literal, and the literal carries the workflow's own placeholders. Written plainly (`"…${GH_TOKEN}…"`), `no-template-curly-in-string` flags it; written with escapes (`"…\${GH_TOKEN}…"`), `no-useless-escape` flags every escape instead — a plain string has no spelling this oxlint accepts. A regex literal is not an escape hatch either: `prefer-includes` rejects a pattern that is a static string. The one spelling both rules accept is a template literal with `\${`: there the backslash is the documented way to write a literal placeholder, and neither rule applies. diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md new file mode 100644 index 00000000..0ee8f744 --- /dev/null +++ b/docs/improvement-plan.md @@ -0,0 +1,682 @@ +# Improvement plan + +## Context + +The result of an architecture audit of the tree at 1.4.1, and the order in which to act on it. It rests on: + +- six audits: routing, catalog, credentials, config/UI, tests/CI/docs, and the stream path; +- an adversarial review of the plan itself, then a nine-lens review (five reviewers, four investigators) in which two independent skeptics checked every finding: 78 of 82 were upheld and are folded in here; +- the host's own source and data (`deepseek-ai/deepseek-harness`, and the pi 1.1.0 catalog its rc tag pins; the alpha tag pins 1.3.0, which is what the desktop app ships, D14''). File references into the host are relative to that repository's `packages/`. + +**Restacked onto 1.8.0 (November 2026).** The stack was cut from 1.4.1. Since then `main` moved to 1.8.0 and followed the harness onto its **alpha** track (the `0.2.2-alpha.2` cluster, cordis `4.0.6-alpha.1`, pi `1.4.0` exact), and it landed several things the plan had scheduled: the plane-aware price, free flag and refusal (#133), a working retired alias (#135), the synthetic catalogue fixtures (#138), the language-pair gate and the live-run verdicts (#140), and the scripts' pure halves (#143, #144). Each open phase merges its parent, and only P0 merges `main`. What changed in the decisions is under "Owner decisions" (D14'', D22'', D16 and D17 as rewritten by D26, D23, D24, D25, D26), what changed per phase is under "Restack adjustments", and what two community plugins built on the same host taught is under "Lessons from opencode2dsh". A new phase, P-R3, follows P-T3. + +**Outcome:** a plugin that only uses host extension points the host supports; one source of truth per decision; a suite that pins behaviour before it moves; and the owner's product rules below. + +**Each phase is one pull request**, and it updates this document: its row below moves to "merged", and its section is trimmed to what is left. A finding that does not survive its phase's first failing test is struck from here rather than forced. + +## Status + +Phases land as **stacked pull requests**. Each one is based on the previous phase's branch, and the first stacks on P0. Every phase is a 1.x release: there is no major (D6). + +| Phase | PR | Title | State | Restack onto 1.8.0 | +| :-- | :-- | :-- | :-- | :-- | +| P0 | #105 | suite hardening, 10 defect fixes, this plan | in review | **merged `main` 1.8.0**; catalog refreshes and the pricing fix dropped (on `main`), except the one vendor refresh the catalog freshness gate required (798ba2e) | +| P1a | #106 | `fix:` the purge row's refusal and the endpoint copy | in review | **merged P0**; shrunk to the purge row's `RemoteError.message`, the `auto` = `modern` copy and the `opencode-google` regression tests; the warning colour drops its token fallback; the catalog's refusal tests gain three cases `main` lacks; the rest is on `main`, and the debug gate follows D24 | +| P1b | #107 | `chore:` hygiene | in review | **merged P1a**; takes `main`'s tool-fallback plane rule (D25) and keeps only the dead Anthropic branch's deletion; its catalogue fixture and `main`'s `test/fixtures/catalogs.ts` become one module behind the one door | +| P2 | #108 | `feat:` a smaller public API | in review | **merged P1b**; re-retires the wrapper `main` revived in #135 (D9): `scripts/wrapper-forwarder.ts` and its test go again; the barrel stays the host's contract, and every name `main`'s tests reach (`parseAccountRef`, `callUsageRead`, `GO_MODEL_LIMITS`, …) comes through the one door | +| P-T1a | #109 | `build:` pin the host | in review | **merged P2**; redone for both harness channels with no pi patch (D14'', D22''): one record per channel in `test/support/host-versions.ts`, the rc channel's committed lockfile and `scripts/use-channel.ts`, the unit and mock suites on both channels in CI, the nightly host-drift report, and the tripwire that keeps `src` off what the desktop's patched pi changes | +| P-T1b | #110 | `test:` a harness that is the real host | in review | pending: merges P-T1a; re-measured on both channels, newest and floor | +| P-H | #112 | `refactor:` one way to patch a host method | in review | pending: merges P-T1b | +| P-R1 | #113 | `feat:` the plugin owns the plane routes | in review | pending: merges P-H; deletes the Gemini level wrapper and sets no default window or max tokens, pi-ai applies its own (D16, D17, D26) | +| P-R2 | #114 | `fix:` the endpoint family, measured | in review | pending: merges P-R1; gains the L3-L5, L9, L10, L13 probes | +| P3 | #115 | `feat:` Settings complements pi's list | in review | pending: merges P-R2 | +| P-T2 | #116 | `test:` suite structure | in review | pending: merges P3; absorbs `main`'s tests and `docs/testing.md` | +| P-T3 | | `ci:` CI and scripts | planned | shrinks: `main` extracted the scripts' pure halves; gains the bump pull request proposed from P-T1a's nightly host-drift report | +| P-R3 | | `fix:` honest refusals | planned | new | +| P5 | | `refactor:` installers | planned | | +| P6 | | `refactor:` the fetch patch's credential fill | planned | | +| P7 | | `refactor:` one source per table | planned | | +| P8 | | `refactor:` settings table | planned | | +| P9 | | `refactor:` remote seam | planned | | +| P10a | | `refactor:` pure module moves | planned | | +| P10b | | `fix:` catalog state | planned | | +| P11 | | `refactor:` UI on the primitives | planned | | +| P12 | | `ci:` lint warnings gating | planned | | + +## Owner decisions + +| # | Decision | +| :-- | :-- | +| D1 | A model whose reasoning is only on/off is offered as **Off / On**. Old saved Minimal/Xhigh/Max selections on such models break, and the release notes say so. It lands in P-R1, with the catalog-first precedence (D17). | +| D2 | Delete `internalRoutes`. | +| D3 | **The composer shows the user's declared models.** An absent or empty `models` list serves the plugin's servable, non-deprecated catalog for that plane. A plane with no entry at all is not registered. **Settings offers the whole catalog.** | +| D4 | Delete `resolveRoutedKey`. It was exported in dde9d6e and nothing in `src/` has ever called it. | +| D5 | **Serve every mode on the user's own route ids, with no hidden routes.** | +| D6 | **Shrink the public API in a 1.x minor.** There is no 2.0.0: nobody consumes the API. Titles are `feat:`, `fix:`, `refactor:` and so on, with no `!` and no `BREAKING CHANGE` footer. Behaviour changes are named in each PR body and in the README's "Changed in 1.x" notes. | +| D7 | Stacked pull requests: one phase per branch, each based on the previous one. | +| D8 | **The plugin never writes the user's profile on its own, and never overrides the user's configuration at dispatch.** The only write is the card's explicit purge action, which goes through `configEditor`. | +| D9 | Stop publishing `@viztor/dsh-opencode` in P2. The deprecation message already published ("Renamed to …") stays as the final one. **Stands after the restack:** `main` revived the alias and fixed its forwarder (#135, `scripts/wrapper-forwarder.ts`), so the last published alias is a working one, and P2 retires it again, deleting the forwarder and its test. | +| D10 | This document is the plan of record. Each phase PR updates its status row. | +| D11 | The adapter patch existed to merge modes. Once the plugin's provider serves every mode per model (D13), the merge is native and the patch is deleted. | +| D12 | **No upstream changes.** Everything is done inside the current host with the APIs it has. | +| D13 | **The plugin owns the `opencode`/`opencode-go` routes.** pi-ai is kept from claiming them through the config waterfall, and the plugin registers its own adapter under the same ids (F11–F14, run S0). It never deletes another plugin's registration, which the host forbids. | +| D14'' | **The plugin supports the latest release of both harness channels, as each one actually ships, and this repository carries no pi patch** (owner, 2026-11-03). Measured that day: the npm packages force no pi version. `@deepseek-ai/dsh` reaches pi through `dsh-base` and `dsh-llm-pi-ai`, which declares a caret range (`^1.1.0` on `rc`, `^1.3.0` on `alpha`), with no shrinkwrap, no bundled copy, no install script and no runtime version check, and the bridge imports pi as an external. So an npm install of either channel runs pi **unpatched**, at the newest `1.x` the range admits (1.5.0 today on both). The harness's exact pin and its patch exist only in the harness's own workspace, and reach users only through the desktop app, whose build copies that workspace's dependencies (pi 1.1.0 on the `rc` tag, 1.3.0 on `alpha`). That patch removes pi's per-delta re-parse of streamed tool-call arguments and, on 1.3.0, round-trips a model's thought signature on OpenAI-compatible completions. No `src` module reads either, so the patch is invisible to the plugin, and `test/architecture.test.ts` fails the day one starts to. A pnpm patch is keyed to one exact version, so carrying the harness's copy would break every pi bump. Hence: each channel's default install is the channel's cluster with unpatched pi at the newest version the bridge's range admits, pinned exactly in that channel's committed lockfile (the root one for `alpha`, `channels/rc/pnpm-lock.yaml` for `rc`); `scripts/use-channel.ts`, given `rc` or `alpha`, switches the dev install, and `--floor` puts pi at the range's floor (the desktop's pi, unpatched), never committed. Unit and mock run on both channels on every pull request and on the floors nightly. The plugin declares no pi range narrower than the union, `^1.1.0`. pi 1.4.0, which `main` pinned exactly, is nobody's host and is gone. _Replaces D14' (a pinned cell with the alpha tag's patch, and a floating cell) and D14 ("target pi 1.1.0, the version the harness pins")._ | +| 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 the user's route-level `api`, then `openai-completions`; pi's own catalog entry is not in it (D26). `protocolFromModelId` is deleted. **Stands after the restack, rewritten by D26:** a pi release that reshapes its own catalog (1.5.0 moved its bases to per-protocol roots) cannot move the routing, because nothing here reads it. Thinking levels are pi's: P-R1 deletes its catalog-driven level wrapper (`src/host/google-levels.ts`), `main`'s hard-coded bridge (#130) goes with the module P-R1 deletes, and a pi that lacks a level rule the vendor needs is a host limitation (D26). | +| 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. **Past that the plugin sets nothing:** pi-ai applies its own default context window and output tokens. Those defaults are the bridge's (`dsh-llm-pi-ai`), not pi's, so they follow the channel whichever pi it runs (1_000_000 and 128_000 on the alpha bridge, 262_144 and 32_768 on rc's), and no copy here can go stale and understate a window. _Rewritten by D26 (2026-11-03); it used to restate pi-ai's defaults and pin the copy with a test against the installed pi-ai. P-R1 implements the rewrite._ | +| D18 | **Settings keeps showing the planes.** One read-only wrapper on `settings.describe` puts the plane entries back into pi-ai's displayed value (F20). The user's config is never written: the Models page writes only through `settings.mutate` path edits (`client/ui-settings-models/src/client/operations.ts:87`). | +| D19 | **Plugin settings.** _Stands after the restack: `main`'s copy says `auto` "follows the catalog", but its code agrees that `auto` is `modern`; P1a's copy wins, pinned by `endpoint-copy.test.ts`._ Delete `modelOverrides`, `maxContextWindow`, `modelRoutes` and `internalRoutes`, and the README's nonexistent `hiddenModels` (removed in P0). Keep `endpoint` (`legacy` or `modern`, with `auto` an alias of `modern`). Keep `providers`, redefined as the route ids whose turns the plugin decorates, with both planes always included. | +| D20 | **The plane adapter resolves keys by pi-ai's rule.** A named ref resolves through the credentials service, then the launch environment, then fails with `MISSING_CREDENTIAL` and sends no request. With no ref, pi's ambient key applies, plus the `readOpencodeStore` opt-in. Captured and literal keys never feed the adapter; they stay in the fetch patch's fill for an empty or placeholder wire key. | +| D21 | The keyless paid-model filter on listings is dropped (D3, D8). Without a key, dispatch fails loudly. | +| D22'' | **Two channel records, named once** in `test/support/host-versions.ts`: `rc` = `dsh@latest` (`0.2.1-rc.5`, cordis `4.0.5`, loader `1.0.6`, schemastery `3.18.5`, bridge pi `^1.1.0`) and `alpha` = `dsh@alpha` (`0.2.2-alpha.2`, cordis `4.0.6-alpha.1`, loader `1.0.7-alpha.1`, schemastery `3.18.6-alpha.1`, bridge pi `^1.3.0`), each with the newest pi its range resolves (the lockfile's) and the range's floor (the harness's pin). The committed `package.json` is `alpha`'s, which is what `main` follows; the devDependencies gain `dsh-llm`, `dsh-app-boot`, `dsh-config-editor`, `dsh-settings` and `cordis-plugin-loader` for the real-host tiers. `test/host-versions.test.ts` checks the installed tree against the SELECTED record (`DSH_CHANNEL`, else the switch's marker, else `alpha`); `test/harness-channel.test.ts` keeps `main`'s both-channel peer-range rule and holds each committed lockfile to its record. A bump inside the prerelease tuples the peer ranges admit is version data only, never `src` or test logic; CONTRIBUTING's release step 3 is the one list of what it moves (for `rc`, the record and `channels/rc/pnpm-lock.yaml`; for `alpha`, the record, `package.json`'s exact pins and the root lockfile; for either, the README pair's "today" sentences, which `test/harness-channel.test.ts` holds to the records, and `minimumReleaseAgeExclude` lines for a cut under a day old). pi moves the same way, through `pnpm update @earendil-works/pi-ai --no-save` and then `rc --relock`, since neither `pnpm install` nor a relock moves a pi the lockfile already satisfies; F10 is keyed by pi line, so a pi inside its line touches no test. A cut that opens a new prerelease tuple (dsh `0.2.3-*`, cordis `4.0.7-*`) is a reviewed range change: it adds the matching peer clause and the README pair's peer-range line. Any change to `package.json`'s dependencies or the workspace's catalog or overrides relocks rc in the same commit, and Dependabot ignores the cluster and pi. A nightly step reports drift against the records (P-T1a: CI's `host-drift` job runs `scripts/report-host-drift.ts`, which reads `dsh` at each record's dist-tag and cordis, the loader, schemastery and pi at the newest version the bridge's declared ranges admit, and prints a warning per field, never a gate); P-T3 turns the report into a proposed bump pull request. _Replaces D22' (the alpha cluster alone, with `rc` only as a peer range) and D22 (the 0.2.1-rc.2 cluster)._ | +| D23 | **CI's freshness checks gate a pull request only on its own inputs.** A check's inputs are its script and every local module it loads, plus `src/identity.ts` for the CLI version; the generated tables never are. Vendor drift warns, and the nightly run proposes the refresh. The strict input lists in `test/freshness-scope.test.ts` are the record: `cli:version`'s grew by `scripts/vendor-version.ts` when `main` gave the check a pure half (#144), and the copy scan's generated-table skip moved into that half. | +| D24 | **The debug gate is `main`'s rule:** naming `debugFile` writes both halves of the log; `debug` governs console output only. `main` chose it (#133), and its README example (`debug: false` with a `debugFile`) relies on it. P1a adopts it, and turns its "writes nothing while debug is off" test into one that asserts the rule. | +| D25 | **The tool fallback's plane is `main`'s rule:** the request URL decides the plane, with a plane-blind lookup as the fallback (#138, `coreToolDecision`). P1b drops its route-mapped variant with no fallback. | +| D26 | **Host gaps are the host's job** (owner, 2026-11-03). The plugin never compensates for, re-implements or re-derives what the host's pi does, and never sniffs a channel or a version. So it deletes the Gemini thinking-level wrapper (`src/host/google-levels.ts`, which re-implemented pi 1.3's rule for an older pi), restates none of pi-ai's defaults (D17), and takes no base URL or protocol from pi's own catalog entries: the endpoint family table, the vendor's endpoints, is the only base source, and the protocol is the catalog's SDK, then the route's `api`, then completions (D16). It keeps what only the plugin can know (the gateway headers, the free-tier tool gate, the catalog's SDK to protocol, the endpoint families, per-plane credentials, the vendor's retirements) and what follows from owning the routes (the describe graft, listing declared ids). Google on the Go plane is routing, not a gap-fill: the plugin's adapter builds every catalog protocol for both planes through pi's public `createProvider`. Where a host's pi lacks something the vendor needs (Gemini 4 thinking levels on pi 1.1.0), that is a characterization row tagged `HOST_LIMITATION` with the channel and pi it applies to; live and mock cases that would exercise it skip by feature detection (pi's own exported predicate, never a version compare), a "Host limitations" list in the docs records it, and it is reported upstream. `test/architecture.test.ts` fails if `src` reads streamed tool-call deltas, partial arguments or thought signatures, so the desktop's patched pi stays invisible (D14''). P-T1a records this row and adds that tripwire. P-T1b tags only the characterization rows that already differ between the channels (F10). P-R1, the phase that removes the compensation, owns the rest: it deletes the wrapper and the restated defaults and rewrites the protocol order, and because the deletion is what exposes the limitation, it also adds the `HOST_LIMITATION` row (Gemini 4 thinking levels, channel `rc`, its floor pi `1.1.0`), the feature-detected skips in the mock case that inherits `google-thinking.test.ts`'s intents and in the live Gemini-level case (keyed on pi's own exported predicate answering for a Gemini 4 id, never on a version), the "Host limitations" list (in both READMEs, paired, or one doc they both link to), and the upstream report, recorded in its pull request. | + +## What the host supports (read from source) + +- **Routes.** + - `registerAdapter` is all-or-nothing against a `private adapters` map (`llm/src/index.ts:399-460`). Only a registration's owner moves its routes, through `handle.replace`. + - `adapter-replacement.ts` deletes another plugin's entry, which is unsupported. Delete it. +- **Dispatch.** + - The `llm/stream` waterfall allows a short-circuit (`index.ts:62-75`). `next()` takes no arguments, and loop requests are frozen. + - Today's redirect, `llm.stream({ ...options, provider: hiddenRoute })` (`src/stream-hook.ts:251-266`), is supported but needs a registered hidden route. A route the plugin owns (F6, D13) keeps `adapterStream`'s validation, projection, replay filtering and failure normalization (`index.ts:1064-1150`) **without** one, so the redirect goes in P-R1. +- **Effort validation goes through the ADAPTER, not the public `resolveModelInfo`.** + - The paths are `resolveCallConfig` → `resolveModelInfoFor` → `adapter.resolveModel`, `adapter.prepareCall`, and pi's private `modelOf` (`index.ts:769,894-905,964,1076`; `llm-pi-ai/src/adapter.ts:369-375`). + - Today the adapter patch merges modes only for Go→Gemini, and through a hidden route (`models-discovery.ts:735-760`). Its catalog fill of context and levels for same-mode models (`:790-1010`) decorates the picker while dispatch validates against a different profile. Once the plugin owns the route (D13), one adapter answers both, in D17's order: the user's row, then the catalog, then pi. +- **Hiding.** + - There is no visibility flag (`LlmProviderInfo = {id, name}`). + - `listProviders()` is read by: + - the composer (`session-controller/src/catalog.ts:24`) and `modelAvailable` (`:81`); + - the Settings directory (`ui-settings-models/src/client/store.ts:233`); + - the subagent tool (`tool-subagent/src/list-models.ts:20,50`); + - ACP (`acp/acp/src/model-control.ts:158`); + - the SDK server (`sdk/server/src/server.ts:423`); + - the translator (`experimental/translator/src/deepseek.ts:69`). + - With no hidden routes (P-R1), **no listing patch is needed**; today's exists only to hide them from these readers. (Instance patches do reach Remote calls, because the gateway resolves methods with `Reflect.get` at call time — `api/gateway/src/index.ts:841-843` — which the `discoverModels` wrapper relies on.) +- **Settings discovery is keyed by the pi-ai entry id** (`llm-pi-ai/src/index.ts:138,156`; `ProviderEditor.tsx:254`; `ModelListEditor.tsx:167,244`). The plugin's own `registerModelDiscovery` namespaces are never asked. + - The host's normalization runs inside the original `discoverModels` (`index.ts:618-631`), so the wrapper must dedupe and whitelist the fields itself. + - `LlmDiscoveredModel` carries only id, name, contextWindow, maxTokens and inputModalities, with no reasoning field. +- **D1 representation.** Effort ids must be pi levels (`adapter.ts:170-181`), and two vocabularies mean opposite things by `null`: + - pi `thinkingLevelMap`: `{ minimal: null, low: null, medium: null, high: "high" }`, with **no `off` key**. `off: null` hides Off and suppresses the explicit disable (`openai-completions.js:674,690,712`; `anthropic-messages.js:917`). + - profile `reasoningEfforts` dict: `{ off: null, high: "high" }`. Here null means "offered, send nothing" (`llm-pi-ai/src/catalog.ts:705-712`). + - Picker: `{ efforts: [{ id: "off", name: "Off" }, { id: "high", name: "On" }] }`. + - Offer Off only where the protocol can disable reasoning (`adapter.ts:184-196`). + - Verify that `"high"` is what each protocol sends for "on" before relying on it. Today `"on"` goes out verbatim (`openai-completions.js:718`, `anthropic-messages.js:733-735`). + +## Defects found in this audit, and where each now stands (all verified in code) + +| Defect | Where | +| :-- | :-- | +| ~~The deprecated wrapper re-exports a `default` that does not exist, an ESM link error.~~ Fixed on `main` (#135); P2 retires the wrapper (D9). | `scripts/publish-scoped.ts:137`, `release.yml:124` | +| Settings shows pi's snapshot, not the catalog | `models-discovery.ts:142-198,532-539` | +| Mode merging is done only for Go→Gemini. For the other modes the picker shows catalog levels while dispatch validates against a different profile: toggle models show Minimal…Max with no Off, and Go `minimax-m3` + Xhigh/Max is refused on the internal Anthropic route. | `models-discovery.ts:607-616,735-760,1063-1203`; `pi-provider.ts:351-386`; `responses-provider.ts:111-150` | +| ~~`isZenProvider("opencode-google")` is false~~ Fixed on `main`: only `opencode-go` and `opencode-go-*` are excluded. P1a adds the regression test. | `utils/request-shape.ts:273-282` | +| ~~`enrichModelsResponse` turns a 401/403/5xx into a 200 catalog. It must still fill the Go plane's 404.~~ Fixed on `main` (#133): a refusal other than 404 passes through. P1a adds the Zen-plane 404 fill, the non-JSON 200 answer and the retry-after passthrough cases. | `models-catalog.ts:731` | +| ~~The warning token doesn't exist, so warnings render as caption text.~~ Fixed on `main` (#133): both use `--dsw-alias-state-warn-label`. | `settings-card.tsx:159`, `settings-store-inspect.tsx:250` | +| ~~The Settings spinner animates only if the meter is mounted~~ Fixed on `main` (#133): the card carries its own keyframes. | `settings-usage.tsx:45` | +| ~~The stream hook writes `debugFile` with `debug` off~~ Not a defect under D24: naming `debugFile` writes both halves. | `stream-hook.ts:327-340` vs `lifecycle.ts:361` | +| `settings-purge`: the `{ ok: false }` branch and the parser are on `main` (#133). ~~Still open: a refused purge whose `error` is the host's `RemoteError` object, not a string, shows the generic message.~~ Fixed by P1a: `settings-purge.ts` reads the `RemoteError`'s `message`. | `settings-purge.ts:56-69` | +| ~~The stream hook's `prepared` handling is dead. The host never reads `options.prepared`.~~ Fixed by P1b: deleted from `stream-hook.ts`. | `stream-hook.ts:44,200-203,258` | +| ~~The partition test misses `debugFile`~~ Fixed by P1b: `settings-fields.ts` registers it and the partition test walks `Config.dict`. | `test/settings-page.test.tsx:1223` | +| ~~`cordis.patch.yml`: phantom `showUsagePrice`, wrong `freeModelMarker: ''` text, incomplete field lists~~ Fixed by P1b: `test/manifest.test.ts` holds the header to the schema. | `cordis.patch.yml:23,36,61` | +| ~~`utils/retry.ts` uses `console` and global timers, against the purity gate~~ Fixed by P1b: `sleep` and `warn` are parameters (`backoffOver(timer)`). | `utils/retry.ts:31,55` | +| ~~`tool-fallback`: the Anthropic branch can't be reached.~~ Fixed by P1b: the Anthropic schemas are deleted. (The Go-first lookup is fixed on `main`; the plane rule is D25.) | `tool-fallback.ts:204,237-242` | +| Hard-coded catalog counts in tests, which the nightly refresh PR breaks by construction | `test/catalog.test.ts:94,200,208` | +| ~~`.freebuff/project-id` is tracked~~ Fixed by P1b: `.freebuff/` is ignored and untracked. | repo root | + +## Routing: the problem, the measured facts, the design + +**The problem, in the owner's words.** A provider defines one backend. Supporting every protocol and the newer catalog meant un-registering the original `opencode`/`opencode-go` provider and registering the plugin's own. Deleting another plugin's registration is unsupported, so the plugin mounted extra routes per protocol instead, merged their models back into the user's provider, and redirected dispatch. This section shows that the host **does** allow the original intent, through supported paths (F11–F13): keep pi-ai from claiming the two ids, and register the plugin's provider under them. A spike on the real host confirmed it (run S0). + +**Facts from the host's own code and data.** F1–F5 and F10 are measured: the data files were counted and the resolver read. F6–F9 and F11–F13 are read from the code, and run S0 exercised F11–F13 end to end. P-T1's real-host harness pins them all, and a fact that fails there sends the design back to this section. + +| # | Fact | Evidence | +| :-- | :-- | :-- | +| F1 | **One route already serves every mode, per model.** llm-pi-ai resolves `api` and `baseUrl` per model: `request.api ?? base?.api ?? routeApi` and `request.baseURL ?? base?.baseUrl ?? providerBaseUrl`, where `base` is pi's installed catalog entry. | `llm-pi-ai/src/catalog.ts:917-922` | +| F2 | _Measured on pi 1.1.0, the pin at the time. Since then, per pi: on 1.3 and 1.4 Go carries Google (F10) and pi stores `/inference(/go)` (Anthropic) and `/inference(/go)/v1`; 1.5.0, both channels' default install (D14''), stores one root per protocol, `/inference(/go)/{anthropic,openai/v1,google/v1beta}` (and Zen's `/inference/systemone/v1`). Routing reads none of them: the endpoint family table is the only base source (D16, D26). G1 and G2 carry the same note._ **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 | _Holds on pi 1.1.0 (rc's floor, the pi the rc desktop ships); gone on 1.3 and later, whose `providers/opencode-go.js` wires `google-generative-ai`. The row is per pi line (`mount-protocols`; tagged per channel by P-T1b), and the plane adapter's Go Google wiring is routing, not a gap-fill (D26)._ **pi 1.1.0's `opencode-go` provider has no Google API.** Its api map is anthropic-messages, openai-completions and openai-responses only, each wrapped in `withOpenCodeSessionHeader`. pi exports each protocol separately (`api/*.lazy`: `anthropicMessagesApi`, `openAICompletionsApi`, `openAIResponsesApi`, `googleGenerativeAIApi`) plus `createProvider` (`models`), which is what `google-bridge.ts` `googleProviderFor` already composes from. | pi 1.1.0 `dist/providers/opencode-go.js`; its `package.json` `exports` | +| F11 | **pi-ai's routes are exactly the keys of its _resolved_ config.** It registers `[...profiles().keys()]`, and `profiles()` reads `config.providers.get()`, the value the fiber resolved. | `llm-pi-ai/src/index.ts:166-175,293-312` | +| F12 | **Every path to that resolved value goes through the `internal/config` waterfall, and the fiber keeps the waterfall's _return value_.** That covers the first load, `fiber.update`, the loader's volatile commit, and the config editor's validation. **The config editor writes the raw `next` to `cordis.patch.yml`, never the resolved value.** So an outermost listener that returns a copy without `providers.opencode`/`providers["opencode-go"]` keeps pi-ai from seeing or registering them, while the user's file keeps every entry. | the harness's `vendor/cordis/src/fiber.ts:641-643,736-752` and `vendor/loader/src/config/entry.ts:164-195`; `boot/config-editor/src/index.ts:101-123` | +| F13 | **The plugin can then register those ids through the public API.** With no pi-ai entry for them, `registerAdapter(["opencode", "opencode-go"], adapter)` passes the duplicate check. `PiAiAdapter` is a **public export** of `@deepseek-ai/dsh-llm-pi-ai`. pi-ai's Settings directory keeps listing both ids, because it lists every installed catalog provider regardless of profiles, with `settingsNs` = pi-ai and `settingsPath = ["providers", id]`. So Settings edits the same entries in the same file as today. | `llm/src/index.ts:399-460`; `llm-pi-ai/src/index.ts:77,128-150` | +| F14 | **A commit and a validation are told apart by identity.** The config editor runs the waterfall on a fresh candidate only to validate it, and can still reject the edit after that run. Every commit path passes the object stored in `fiber._config`, so `raw === this._config` holds for a commit and for nothing else. | `boot/config-editor/src/index.ts:101-148`; the harness's `vendor/cordis/src/fiber.ts:229,655,738,747` and `vendor/loader/src/config/entry.ts:149,171` | +| F15 | **An edit that changes only a plane entry emits nothing.** It raises no `loader/volatile-update` and does not restart pi-ai. The listener's commit run is the only signal it happened. | the harness's `vendor/loader/src/config/entry.ts:177-187` | +| F16 | **The outermost listener sees un-interpolated `{__jsExpr}` nodes.** The loader interpolates only the value `next()` returns. | the harness's `vendor/loader/src/index.ts:104-113` | +| F17 | **Ordering must be explicit.** `restart()` is swallowed while a fiber is loading or unloading, loader rows load in parallel, and cordis disposes effects concurrently. | the harness's `vendor/cordis/src/fiber.ts:625-639,665-686` and `vendor/loader/src/config/group.ts:56` | +| F18 | **`PiAiAdapter.listModels` answers `[]` on `MISSING_CREDENTIAL`.** | `llm-pi-ai/src/adapter.ts:295-311` | +| F19 | **`PiAiAdapterOptions.auth` is required.** pi-ai does not export its credential-store or auth-context builders; it does export `PiAiAdapter`, `Config` and `recordKeyFor`. | `llm-pi-ai/src/adapter.ts:98-106`; `index.ts:77-92` | +| F20 | **The Settings page reads pi-ai's resolved config.** `describe()` builds `value` from `fiber.config`, so once the claim strips the planes they read as unconfigured. The gateway resolves service methods per call (`Reflect.get`), so an instance wrapper on `describe` is seen by `settingsController.describe` and by `write()`. | `settings/settings/src/index.ts:319`; `api/gateway/src/index.ts:841-843`; `api/settings-controller/src/index.ts:98-103,217` | + +Three corrections to the facts above: + +- **F12, the mechanism.** Cordis passes listener arguments unwrapped (the harness's `vendor/cordis/src/utils.ts:118-125`). The first prototype's in-place trim failed because the fiber keeps the waterfall's return value, not because of a proxy. +- **F13, the read view.** Settings writes the same file, but its read view (`describe().value`) loses the plane entries. The plugin grafts them back (D18, F20). +- **Run S0's other routes.** `deepseek-official` and `deepseek-account` belong to the `llm-deepseek-*` plugins, not to pi-ai. + +**What the plugin has to add, and nothing else:** + +- **G1:** _Measured on pi 1.1.0, the pin at the time; what later pi stores is F2's note._ serve any declared model the host's pi cannot resolve (D15). That means models newer than pi 1.1.0's catalog (8 Zen and 4 Go, including every Gemini model on Go, F10), **and** ids the catalog doesn't know at all. +- **G2:** _Measured on pi 1.1.0, the pin at the time; what later pi stores is F2's note._ 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) > the user's route-level `api` (only for ids the catalog does not classify) > `openai-completions` (D16, D26) | + | name, context, max tokens, modalities, cost | the user's row > pi-ai's route-level `modelOverrides` > catalog > pi; past that pi-ai's own defaults, which the plugin does not restate (D17, D26) | + | 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 the fixed legacy table, never from pi's own entry (D26). Go has no legacy Google base, so that cell uses `OPENCODE_FAMILY_API.go["@ai-sdk/google"]`. + +- **`src/utils/protocols.ts`**, created in P-R1, holds the SDK constants, `PROTOCOL_FOR_SDK` and `isServableSdk`. `UNSERVED_*` goes. + +#### `src/host/patch-method.ts`: the two host wrappers that remain + +`patch-method.ts` restores a wrapper only if it is still the current one, deletes it when the original was inherited, and re-wraps on `llm/adapters-updated`. It installs two wrappers: + +- **`settings.describe` (D18, F20).** It runs inside `ctx.inject(["settings"])`, and touches only the `llm-pi-ai` descriptor. When `value.providers[plane]` is missing, it sets it to the interpolated, `Config`-normalized committed entry; `base`, `user` and the revision are untouched. A contract test pins that no node under `providers` in pi-ai's `Config` carries `role("secret")`. Today only `apiKeyEnv` has a role, and it is `credential-ref`. +- **`discoverModels` (P3).** It answers pi's rows plus the catalog's missing ones, deduped and whitelisted. A `NO_DISCOVERY` while pi-ai restarts yields catalog-only rows. + +#### Deleted + +- the llm-pi-ai child mount and its `llmFacade`; +- `responses-routes.ts`, and the hidden ids in `DEFAULT_PROVIDERS` and `cordis.patch.yml`; +- the listing hide; +- the stream redirect and `isAvailable`; +- `decoratePlaneAdapters`, and the plane overrides in `decorateModelInfo`; +- the Gemini bridge registration; +- the Google replay translators, if replay stays on one provider label (a test decides); +- `protocolFromModelId`, `adapter-replacement.ts`, and the D19 settings. + +#### Removing the plugin + +pi-ai serves the models it knows again, from the same file. Ids pi can't serve drop out of the composer, and the next Settings edit of that provider is refused until they are removed. Three things cover this: + +- a README section; +- a handback test that pins the behaviour; +- a card action that lists those ids and removes them (P10b). + +### Measured on the real host + +**Runs.** + +- Runs A–D use the published `@deepseek-ai/dsh` 0.2.1-rc.2 with the pi 1.4.0 it installs, offline, with an instrumented fetch. +- Runs S0 and S0b swap pi for **1.1.0 plus the harness's own patch** (the pin of the time, D14; under D14'' the suite runs pi unpatched, at each channel's newest and at its floor). +- The prototypes are throwaway, and are not in the repository. + +| Run | Registered | Known models, 4 shapes × 2 planes | A model pi lacks | Saving a model pi lacks | +| :-- | :-- | :-- | :-- | :-- | +| A: plugin 1.4.1 as shipped | `opencode`, `opencode-go` + 6 hidden routes | served through the hidden routes, on the plugin's per-shape endpoints | **not listed, `INVALID_CONFIG`, never sent** | **rejected** ("needs an api") | +| B: pi alone | `opencode`, `opencode-go` (pi-ai) | served, each in its own protocol, on pi's endpoints | same failure | **rejected** | +| C: pi + a delegating prototype (save gate, unregistered catalog adapter, adapter patch) | `opencode`, `opencode-go` (pi-ai) | served by pi | listed, validates, sent | accepted for catalog models; **a catalog-unknown id still rejected (run D)** | +| **S0/S0b: pi 1.1.0 + route claim + plugin provider** | `opencode`, `opencode-go` → **the plugin's adapter**; pi-ai's `deepseek` route unchanged; no hidden routes | all listed and validated, and each sent in its own protocol: `/inference/openai/v1/chat/completions` and `/responses`, `/inference/anthropic/v1/messages`, `/inference/google/v1beta/models/…:streamGenerateContent`, and the same paths under `/inference/go/` on Go, **Gemini on Go included** | Zen `gpt-5.3-codex-spark` → `/responses`, with the user's `contextWindow` reaching `resolveModelInfo`; Go `minimax-m3.1-flash` → completions; Go `gemini-4-argon-contributor` → Google | **`not-a-real-model` accepted**: the file holds exactly the saved entry, which is then listed, validates and is sent as completions | + +**What the runs did not settle.** + +- **Handback.** S0's handback **failed**: both planes ended at `NO_ADAPTER`, because the prototype released them while its listener was still live. S0b, after that fix, handed both routes back, and pi-ai then listed what pi 1.1.0 knows. +- **What the spike exercised.** It claimed the routes 2 s after boot, against an ACTIVE pi-ai, and simulated the release rather than disposing a fiber. P-T1b and P-R1 measure the real lifecycle. +- **A Google failure code.** A 401 on a Google call surfaced as `PI_AI_ERROR` rather than `AUTH`, exactly as with pi alone (run B). That is pi's error mapping. +- **Endpoints.** The sandbox blocks `opencode.ai`, so P-R2 measures the endpoints in CI. + +**Fallback, only if the P-T1b harness contradicts S0:** run C's shape. That shape has three parts: + +- an unregistered catalog adapter per plane, serving the gap models; +- an adapter patch on pi-ai's route adapter, delegating those models to it; +- a save gate that validates a copy of the config without them, then absorbs only pi-ai's "needs an api" refusal for those ids. + +## Target architecture + +``` +src/ + index.ts apply, Config, inject, name + config types (D6) + lifecycle.ts apply(): installers only + host/ + patch-method.ts wrap a host method: restore only if still current; delete when the original + was inherited; re-wrap on llm/adapters-updated (settings.describe, discoverModels) + discovery.ts discoverModels: pi's answer + the catalog's missing models (D3, D8) + route-claim.ts prepended internal/config filter: pi-ai never claims the planes; reconcile on + commit; mount and teardown ordering (F14-F17) + plane-provider.ts PlaneAdapter (extends the host's PiAiAdapter), registered as the declared planes + remote.ts installRemoteService, markRemote + request/ fetch patch stages: claim → credential → headers → body (body parsed once) + credentials.ts gathers values; utils/credential-policy decides; CaptureStore per root + utils/ pure: protocols, plane-model, plane-profile, model-metadata (reasoning, both + vocabularies), credential-policy, request-shape, session-id, catalog-parse + contracts/ defineRemote(namespace, methods) for usage, purge, store (client-safe) + settings-table.ts one field table → schema, resolver, types, card specs, copy, manifest + ui/ card, one ActionRow, meter, on the primitives +``` + +The target removes the workaround's moving parts. There are no hidden routes, no listing hide, no stream redirect and no child mount. There is no Gemini bridge registration, no patched host adapter and no `resolveModelInfo` override (D8). + +What stays private is pinned by contract tests against the real host packages: + +- the `ResolvedPiAiProviderProfile` shape; +- pi-ai's credential record mapping; +- parity with pi-ai's own profile resolution; +- the two wrapped host methods, `describe` and `discoverModels`. + +## Phases + +**How each phase opens:** + +- **Behaviour phases open with a red test:** P1a, P1b's fixes, P2, P-R1, P-R2, P3, P6 and P10b. +- **Pinning phases open with a green pin**, which the PR shows unchanged: P-T1a/b, P-H, P5, P7, P8, P9, P10a and P-T2. +- **P-T3 and P12 are exempt.** + +**Characterization rows are tagged when they are recorded**, so a later phase flips named rows: + +- `KNOWN_DEFECT`: + - Go Gemini answers `INVALID_CONFIG`; + - an unknown id is rejected; + - toggle models show Minimal/Xhigh/Max. +- `INTENDED_CHANGE`: + - completions models move from `/zen(/go)/v1` to `/inference(/go)/openai/v1`; + - `decoratePlaneAdapters`' baseUrl repair never reaches the wire. + +1. **P1a `fix:` the purge row's refusal and the endpoint copy.** The rest of what shipped broken in 1.4.1 was fixed on `main` (#133, #135) before this phase landed. + - `settings-purge` shows the `message` of a refusal whose `error` is the host's `RemoteError` object. + - The endpoint copy (`config.ts`, `config-values.ts`, the card, both READMEs) names the per-protocol inference family for `modern`, with `auto` as its alias (D19), pinned by `endpoint-copy.test.ts`. + - Regression tests for `isZenProvider("opencode-google")`: the predicate, its agreement with `catalogPlaneForRoute`, and the usage service's Zen fallback on that route. + - The warning colour is the bare `--dsw-alias-state-warn-label` (no token fallback), and every token the card names is checked against the host's vocabulary. + - The stream hook's debug test asserts D24 rather than the gate this phase first proposed. + - The catalog's refusal tests gain three cases `main` lacks: the Zen-plane 404 fill, the non-JSON 200 answer, and a `retry-after` header that survives the passthrough. The passthrough itself is on `main` (#133). +2. **P1b `chore:` hygiene.** + - Delete `adapter-replacement.ts` and its test, with a note in the engineering notes on why the approach is unsupported. + - Delete the dead `prepared` handling and `.freebuff/`. + - The partition test walks `Config.dict`, and a manifest test checks `cordis.patch.yml` against it. + - `retry.ts` takes its timer and logger as parameters. The architecture test bans `console` and timers in `utils/`, and walks the folder recursively. + - Remove the dead branches in `tool-fallback`. + - Add the `test/support/src.ts` indirection. + - On the restack: the tool fallback keeps `main`'s plane rule (D25), so only the unreachable Anthropic schemas go; the catalogue fixture this phase wrote and `main`'s `test/fixtures/catalogs.ts` become one module under `test/support/`, which reaches `src` through the door. +3. **P2 `feat:` a smaller public API.** It ships as a 1.x minor. + - `index.ts` exports `apply`, `Config`, `inject`, `name` and the config types. + - `./src/*` goes from `exports`, and `check.ts` asserts that every exports target is inside `files`. + - Delete `resolveRoutedKey` and `internalRoutes`. + - Stop publishing the wrapper. `publish-scoped.ts`'s `SCOPED_TARGETS` and `release.yml`'s verify list change together, and `check.ts` asserts that they agree. +4. **P-T1a `build:` pin the host.** + - `test/support/host-versions.ts` names both channels' hosts (D22''), and `package.json` keeps `main`'s alpha pins, with `dsh-llm`, `dsh-app-boot`, `dsh-config-editor`, `dsh-settings` and `cordis-plugin-loader` added, and pi at the union range `^1.1.0`. + - No pi patch and no pi override (D14''): `patches/` and `patchedDependencies` go. The root lockfile pins alpha's newest pi; `channels/rc/pnpm-lock.yaml` pins rc's; `scripts/use-channel.ts` switches between them, and to a channel's floor. + - Tests: the installed tree against the selected record (`host-versions`), each lockfile against its record (`harness-channel`), the switch's pure half (`use-channel`), and the tripwire that keeps `src` off streamed tool-call deltas, partial arguments and thought signatures (`architecture`). + - `mount-protocols.test.ts`: "every catalog SDK maps to a protocol that pi's `api/*` exports implement"; the F10 row is per pi line (no Go Google on 1.1, native from 1.3 to the next major). + - CI: the unit and mock suites run as a matrix over `[alpha, rc]` on every pull request, and on each channel's floor nightly and on dispatch. + - CI: a `host-drift` job (schedule and dispatch, report-only, exit 0) runs `scripts/report-host-drift.ts`, whose pure half is `scripts/host-drift.ts`. + - Dependabot ignores the cluster and pi (a bump follows D22''), and each lockfile is held to the manifest and workspace it was locked from, so a dependency change that skips `rc --relock` fails by name. +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`. + - **Harness bumps:** turn the nightly host-drift report (P-T1a's `host-drift` job) into a proposed bump pull request that moves exactly the set CONTRIBUTING's release step 3 lists, and nothing else (D22''); a cut that opens a new prerelease tuple goes up for review as a range change, not as data. +12. **P-R3 `fix:` honest refusals.** After P-R2 has recorded the shapes (lessons L2, L7, L9, L10 below). + - `PlaneAdapter.prepareCall` classifies the gateway's refusals as non-retryable and keeps their status and body: `FreeTierError` (403) and `FreeUsageLimitError` (429) as the free tier's refusal; `RegionError`, `ModelDeprecated` (410) and `ModelProtocolUnsupported` (400) as "model unavailable". First verify whether an `AUTH` code makes the conversation UI replace the gateway's message, which decides where the codes land. + - Set `tool_choice` when the gate tools are injected (`none` on chat completions with no original tools; never on Responses, which accepts only `auto`), and treat a `read`/`bash` tool with empty `properties` as missing. + - Fill dsh-llm's `providerRetryAfterMs` from `Retry-After`, if P-R2 measured it on Go's 429s. + - Offer Off through `reasoning_effort: "none"` on plain chat-completions reasoning models, if P-R2 measured it. +13. **P5 `refactor:` installers.** `installRemoteService` replaces the three mount-once copies. +14. **P6 `refactor:` the fetch patch's credential fill.** The adapter path is already D20. + - One resolver, for an empty or placeholder wire key only. + - A parity test over the deciders, with the adapter path as its own row: a rotated key takes effect, and a service failure propagates. + - A `CaptureStore` per root, evicting a key on 401. +15. **P7 `refactor:` one source per table.** One `planeForRoute` (four copies today), `formatUsd` and `specFor`; delete the duplicate `ALL_THINKING_LEVELS` and the capitalisation copies. +16. **P8 `refactor:` settings table.** It starts with a snapshot test of the derived `Config`. Then one field table drives the schema, resolver, types, card, copy and manifest. +17. **P9 `refactor:` remote seam.** `defineRemote`, `markRemote` and `callRemote`, with the parity test reading the contracts. +18. **P10a `refactor:` pure module moves.** + - `models-discovery` → `host/*`; + - `go-discovery` → `credentials.ts`; + - split `usage-ui`; + - `fetch-patch` → `request/`. +19. **P10b `fix:` catalog state.** + - The parse is pure and returns `deprecated`, and a refresh replaces the maps. + - The purge makes one edit per entry. + - The card lists the ids pi can't serve, and removes them. +20. **P11 `refactor:` UI on the primitives.** +21. **P12 `ci:`** make lint warnings gating once `src/` is at zero. + +## Order + +``` +P0 → P1a → P1b → P2 → P-T1a → P-T1b → P-H → P-R1 → P-R2 → P3 → P-T2 → P-T3 → P-R3 → P5 → P6 → P7 → P8 → P9 → P10a → P10b → P11 → P12 +``` + +- **P1a first:** it fixes what already shipped broken. +- **P-T1a and P-T1b before any routing change:** the real-host harness records today's behaviour, and P-R1 must keep it except for the rows it names. +- **P-H before P-R1:** P-R1 needs the `describe` wrapper. +- **P10a only after P5–P9:** a module is moved only once tests pin what it does. +- **P-R3 only after P-R2's re-measure:** it changes how refusals are reported, so the refusal shapes have to be recorded first. + +## Restack adjustments + +How each phase changes now that it sits on `main` 1.8.0 and on both harness channels' hosts (D14'', D22''; the committed manifest is `alpha`'s, as on `main`). Each open phase merges its parent; only P0 merges `main`. Generated files take `main`'s blobs, except the vendor refresh P0 took unedited from the nightly refresh because its generator change put the catalog freshness gate in force (798ba2e), a no-op once `main` carries the same refresh; catalog counts are re-derived from the shipped shim, the README pair moves in step (`test/docs-pairing.test.ts`), and the repository map stays true in both directions (`test/no-local-paths.test.ts`). + +| Phase | Adjustment | +| :-- | :-- | +| P0 | Shrinks: the three catalog refreshes and the per-plane pricing fix are on `main`; it carries one later vendor refresh unedited (798ba2e), which its generator change made the freshness gate require. Keeps the suite hardening, the lifecycle, mount and credential fixes, D23's scoping with `--copies`, and this plan. | +| P1a | Shrinks hard: the purge row shows the host's `RemoteError.message`, the endpoint copy says `auto` is `modern`, and `isZenProvider("opencode-google")` gains a regression test. The wrapper fix, refusal passthrough, warning token, spinner and `parsePurgeResult` are on `main` or superseded; P1a only drops the warning token's fallback, checks every token the card names against the host's vocabulary, and adds three refusal-passthrough test cases `main` lacks (the Zen-plane 404 fill, the non-JSON 200 answer, the `retry-after` passthrough). The debug gate follows D24. | +| P1b | Takes `main`'s `coreToolDecision` (D25) and ports only the dead Anthropic branch's deletion; its catalogue fixture merges with `main`'s `test/fixtures/catalogs.ts` behind the one door. | +| P2 | Re-retires the wrapper `main` revived (D9); the door list grows by every `src` name `main`'s tests import. On the merge, P1b's and P2's door-rule lines in `CONTRIBUTING.md` conflict; take P2's, which names the same exceptions and adds its gate. | +| P-T1a | Redone on D14'' and D22'' (the owner's 2026-11-03 decision, which settled the question this row used to leave open): both channels' latest hosts, each pinned by its own lockfile, pi unpatched at the newest version its range resolves, the floors one switch away, and a nightly report of drift between the registry and the records; the 1.1.0 patch, the pi override and the patch-hunk tests are gone. | +| P-T1b | Runs tiers A and B on both channels (D14''), the floors nightly; characterization rows carry a per-channel (and floor-versus-newest) expectation where the hosts differ, and an unknown id's default `context` is keyed by channel (the bridge's default, D17), not by pi; contract tests run on both channels; contract-test citations move to cordis `4.0.6-alpha.1`. | +| P-H | Gains the Chinese "Changed in 1.x" bullet the pairing gate requires. | +| P-R1 | Implements D26: deletes `src/host/google-levels.ts` with its wiring, test, mock case, coverage floor and docs lines; sets no default window or max tokens (pi-ai applies its own, D17); takes no base or protocol from pi's catalog entries (D16). Deletes `test/google-thinking.test.ts` with `src/google-bridge.ts`, moving its intents to the mock suite; takes `main`'s model lists with its own routing prose. Owns what the deletion exposes (D26): the `HOST_LIMITATION` row for Gemini 4 thinking levels on `rc`'s floor pi `1.1.0`, the feature-detected skips (pi's own exported predicate) in that mock case and the live Gemini-level case, so the nightly `rc` floor cell stays green, the "Host limitations" list the README's support policy promises, and the upstream report. | +| P-R2 | Splits the free probe's 429: `FreeUsageLimitError` is its own non-retryable verdict, not a rate limit; `ModelDeprecated` reads as gone; adds the probe rows of lessons L3, L4, L5, L9, L10 and L13; ports `main`'s mock-coverage gate (#142) over the dispatch tables. | +| P3 | Unchanged seams on both channels' hosts; its Settings fetch row runs in each channel's binary install, newest and floor. | +| P-T2 | Absorbs `main`'s tests and owns `docs/testing.md`; folds `test/e2e/verdict.ts` into the live-probe module; tests `main`'s plane lookup fallback. | +| P-T3 | **Shrinks.** `main` already extracted the scripts' pure halves and wrote `docs/testing.md`. Remaining: one unit run with the build before the `lib/` readers, `test/**/*.tsx` type-checked, the composite setup action, `check.ts` trimmed to freshness, consumer install and size, `verify-*` exit codes, and `no-local-paths` over `.github/` and `*.sh`. **New:** the CLI-version script reads the registry's dist-tags and the 2.x User-Agent format (L6); the nightly host-drift report P-T1a added becomes a proposed bump pull request moving exactly CONTRIBUTING's release-step-3 set (D22''), and a cut opening a new prerelease tuple goes up for review as a range change. (L13's newest-pi cell is no longer P-T3's: under D14'' each channel's default install already is the newest pi.) | +| P-R3 | **New**, after P-R2's re-measure: L2's error mapping in `prepareCall`, L7's `tool_choice` and schema check, L9's `providerRetryAfterMs`, and L10's Off through `none` if measured. | +| P5 | Unchanged, plus L1's warn-once when `globalThis.fetch` is no longer the patched function. | +| P6 | L8: verify before evicting a key; L4's outcome (drop `x-opencode-request`, or send it only where it is accepted). | +| P7 | L6's client-generation table and L11's context-window overrides table; the `planeForRoute` copies now include `main`'s `isGoPlaneUrl` and `catalogPlaneForRoute` pair. | +| P8 | The health snapshot shows the catalog's `lastError` on the card. | +| P9 | A loopback triple guard, only if a `webServer` route appears. | +| P10a | L1's per-call shaping is the first move of `fetch-patch` into `request/`. | +| P10b | L12's test; optionally emit `llm/models-updated` on refresh, a new host event. | +| P11, P12 | Unchanged. | + +## Lessons from opencode2dsh + +Two community plugins solve the same problem on the same host: [`FishBottle7/opencode2dsh`](https://github.com/FishBottle7/opencode2dsh) (read at `3805a38`, cited as FB) and [`satanfire/opencode2dsh`](https://github.com/satanfire/opencode2dsh) (read at `873e914`, cited as SF). Ranked by value. "Adopt" means the evidence is in the cited code; "measure" means P-R2 records it before any code moves. + +| # | Lesson | Evidence | Phase | Verdict | +| :-- | :-- | :-- | :-- | :-- | +| L1 | **Shape plane traffic per call, not through the global fetch patch.** Put the derived session id, `x-opencode-client` and `x-opencode-project` into `options.headers`, and the core-tool fallback into `onPayload`, when wrapping each api in the plane provider; keep the fetch patch for URL-claimed relays, listing enrichment and P6's credential fill. Warn once when `globalThis.fetch` is no longer the patched function. | FB `packages/plugin/src/adapter/zen-adapter.ts` (`headers`, `onPayload`); FB `packages/plugin/src/pool/installer.ts` swaps `globalThis.fetch`, and our restore is identity-guarded. `docs/redirect-planes.md` claims the headers reach all four protocols; only the session header does. | P-R1 follow-up (the doc fix), P10a, P5 | Adopt | +| L2 | **Classify the gateway's refusals as non-retryable in `prepareCall`**, keeping status and body. The host maps 403 to `AUTH` and 429 to `RATE_LIMIT`, and retries a 429 five times. | SF `lib/errors.js`; FB `packages/plugin/src/adapter/events.ts`. FB's claim that `AUTH` makes the UI print "API key is invalid" is unverified. | P-R3 | Adopt after verifying the UI claim | +| L3 | **The free probe misreads the newer refusal.** A final 429 is always read as rate-limited; `429 FreeUsageLimitError` is the gate refusing, so it is its own verdict and is not retried. Also probe the root family `/inference/v1`. | SF `873e914` (the probe no longer counts a 429 as usable); FB `packages/plugin/src/adapter/catalog.ts` counts a 429 as a pass. | P-R2 | Adopt | +| L4 | **Measure `x-opencode-request` before keeping it.** We send a minted `msg_` id, and the keyed probes omit it, so it has never been measured. | FB `packages/plugin/src/adapter/ids.ts` ("any value is refused"); SF removed it; `docs/upstream-findings.md` says 2.x dropped it. | P-R2 rows, then P6 or P10a | Measure; drop it or send it only where accepted | +| L5 | **`x-opencode-project` has three conflicting claims:** the folder basename (ours, which also leaks the repository's name), `global` (FB), and a 40-hex digest (SF). | FB `packages/plugin/src/adapter/ids.ts`; SF `lib/protocol.js`. | P-R2 rows for all three, then default to `global` unless another is proven | Measure; probably change the default | +| L6 | **One "client generation" table** for the User-Agent format, the header set and the session semantics. Our tree disagrees with itself about the affinity headers on plane routes, and SF ships the 2.x User-Agent format. | FB `packages/plugin/src/adapter/ids.ts`; SF `lib/protocol.js`; `src/fetch-patch.ts`; `docs/upstream-findings.md`. | P7, P-T3 | Adopt | +| L7 | **Set `tool_choice` when injecting the gate tools, and check their schemas.** Ours injects and leaves `tool_choice` alone, and matches by name only, so a title or compaction call can end in a stub-tool call. | FB `packages/plugin/src/adapter/messages.ts`; SF `lib/protocol.js`. | P-R3 (or L1's `onPayload`) | Adopt | +| L8 | **Verify before evicting a key.** A malformed session header yields `401 Invalid API key`, and FB saw the same request refused and then accepted. Evict per plane only after one verification or a second well-formed 401. | FB `packages/plugin/src/pool/death-verify.ts`; `src/utils/session-id.ts`. | P6 | Adopt | +| L9 | **Honour `Retry-After`.** dsh-llm accepts `providerRetryAfterMs`, `dsh-llm-pi-ai` never fills it, and pi's `onResponse` exposes the headers. | SF `lib/errors.js`. | P-R2 records whether Go's 429s carry it; P-R3 fills it | Measure, then adopt | +| L10 | **Offer Off on plain chat-completions reasoning models** through `reasoning_effort: "none"`; ours offers Off only for a few body formats. | FB `packages/plugin/src/adapter/zen-adapter.ts`; `src/utils/model-metadata.ts`. | P-R2 keyed probe, then P-R1's metadata | Measure first | +| L11 | **Context-window overrides with dated evidence.** FB keeps measured windows for two free models that differ from the catalog's; an understated window is the overflow trap. | FB `packages/plugin/src/adapter/catalog.ts`. | P7 | Low priority; the table pattern only | +| L12 | **A failed or partial refresh must not replace the maps.** | SF `lib/catalog.js`; FB `packages/plugin/src/adapter/catalog.ts`. | P10b | Adopt | +| L13 | **Test against the host's newer pi**, not only the pin: unit and tier A on the newest pi the host's range resolves, not just the binary e2e; the `/zen/v1/systemone` family belongs in P-R2's families. | FB's hoisted-pi test and loader hook. | P-T1a (unit and mock on each channel's newest pi), P-T1b (tiers A and B), P-R2 (the family) | Adopt | +| L14 | Smaller: a triple loopback guard if a `webServer` route is ever added (the remotes seam remains the better one); the host's `volatileForm()` as an alternative to hand-written card fields; the catalog's `lastError` on the card rather than in a file. | FB `packages/plugin/src/ip-pool-settings/bridge.ts`, `packages/plugin/src/config.ts`. | P9, P8 or P11, P8 | Adopt where the phase reaches it | + +**Do not adopt:** SF's Bun transport and its `` prompt injection (which also leaks the host server's working directory); FB's IP pool, sibling-host replay and credential-custody delegation; automatic retry with thinking turned off; FB's own stream watchdogs (the host has one). These circumvent the vendor's stated gate ("Free models work in OpenCode"); the vendor answers with IP blocks, and these features would expose our users' keys. Classify the refusals honestly instead (L2, L3). Also avoid their session-id scheme, which is derived from the first user message and re-minted per process: ours is stable per DSH session (`src/utils/session-id.ts`). + +## Test matrix + +**P1a–P2** + +- **Unit:** + - each shipped breakage, red before its fix; + - the wrapper is retired (P2, D9), so it has no forwarder to test; + - a snapshot of the endpoint copy; + - the manifest is a subset of `Config.dict`; + - the `src.ts` indirection; + - the public API, pinned green and then shrunk; + - `exports` targets are inside `files`; + - the scoped publish list matches `release.yml`'s; + - the deleted settings are gone everywhere. + +**P-T1a** + +- **Unit:** + - the installed tree against the selected channel record, and a same-copy check on the realpath; + - each committed lockfile against its record, and no pi patch anywhere; + - the channel switch's pure half; + - the rewritten `mount-protocols` test, with F10 per pi line; + - the tripwire on what the desktop's patched pi changes, the `partial` message's tool-call arguments included; + - the drift report's rules with known answers, the CLI against a loopback registry, and the job's evaluated condition (`host-drift`); + - Dependabot's ignore of the cluster and pi, and each lockfile's specifiers, catalogs and overrides against the manifest and workspace (`harness-channel`). + +**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 each channel's install (D14''), with a per-channel expectation where the hosts differ. + +**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 only in the floor cell. +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) each channel's published binary, with pi as npm resolves it (D14''), on PRs that touch the host seam; +- (b) each channel's floor pi, 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 channels, newest and floor) and the live keyless matrix must be green. The PR body records: + +- the measured endpoints; +- the `describe` graft; +- the handback; +- the save of a catalog-unknown id. diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index c33f0d05..ed6d1ec0 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -4,15 +4,15 @@ How this plugin makes OpenCode's models speak the protocol each of them is actua ## 1. Why routing exists -OpenCode serves its models on **four different API shapes**, and which one a model lives on is a property of the model, not of the account. The per-plane counts, and the tables that go with them, live in [`redirect-planes.md`](./redirect-planes.md) §2; what follows is why the plugin has to care. Measured 2026-10-17: +OpenCode serves its models on **four different API shapes**, and which one a model lives on is a property of the model, not of the account. The per-plane counts, and the tables that go with them, live in [`redirect-planes.md`](./redirect-planes.md) §2; what follows is why the plugin has to care. Wire endpoints measured 2026-10-17; counts as of 2026-11-03 (the shipped shim, regenerated in #132 and refreshed for `pulse-alpha-free`): | API shape | Wire endpoint | Which models | Signal in the catalog | | :-- | :-- | :-- | :-- | -| OpenAI **Chat Completions** | `POST /zen/v1/chat/completions` | **the default**: 23 of 80 | the model names **no** SDK | -| OpenAI **Responses** | `POST /zen/v1/responses` | 31 of 80 | `@ai-sdk/openai` | -| Anthropic **Messages** | `POST /zen/v1/messages` | 17 of 80 | `@ai-sdk/anthropic` | -| Google **Generative AI** | `POST /zen/v1/models/:streamGenerateContent` | 8 of 80 | `@ai-sdk/google` | -| Mistral **Conversations** | `POST /zen/v1/chat/completions` | 1 of 80 | `@ai-sdk/mistral` | +| OpenAI **Chat Completions** | `POST /zen/v1/chat/completions` | **the default**: 24 of 81 | the model names **no** SDK | +| OpenAI **Responses** | `POST /zen/v1/responses` | 31 of 81 | `@ai-sdk/openai` | +| Anthropic **Messages** | `POST /zen/v1/messages` | 17 of 81 | `@ai-sdk/anthropic` | +| Google **Generative AI** | `POST /zen/v1/models/:streamGenerateContent` | 8 of 81 | `@ai-sdk/google` | +| Mistral **Conversations** | `POST /zen/v1/chat/completions` | 1 of 81 | `@ai-sdk/mistral` | Counts are the shipped catalog. They are asserted against this table by `catalog.test.ts`, because a count written into prose does not regenerate with the data it counts. @@ -168,7 +168,7 @@ Two properties matter more than completeness: Cold start is never empty: the bundled shim answers before the first refresh, so the picker has models immediately, and a refresh replaces the set rather than appending to it twice. A missing price is its own answer: when models.dev has no row for a model, the meter shows the model's name and `—` for the rate rather than `Free`, and the panel never falls back to describing the _previous_ model. -Both generated files are checked in CI: `catalog:shim` and `limits:shim` exit non-zero when the vendor moved, which is the only way to notice that models.dev changed under you. Regenerating one is a three-step deploy: regenerate, rebuild the bundles, commit both. The host loads `lib/index.mjs`, not `src/`. +Both generated files are checked in CI: `catalog:shim` and `limits:shim` exit non-zero when the vendor moved, which is the only way to notice that models.dev changed under you. A pull request is gated on that only when its own diff touches the generator or a module the generator loads (`scripts/freshness-scope.ts` decides); any other pull request, and a push to main, warns, and the nightly job proposes the refresh. Regenerating one is a three-step deploy: regenerate, rebuild the bundles, commit both. The host loads `lib/index.mjs`, not `src/`. ## 4. The UI diff --git a/docs/quota-meter.md b/docs/quota-meter.md index f66bcb61..d84864e6 100644 --- a/docs/quota-meter.md +++ b/docs/quota-meter.md @@ -41,10 +41,10 @@ Hovering lists **all three**, because the trigger can print one number and the n │ ──────────────────────────────────────────── │ │ │ │ Monthly allowance │ -│ mimo-v2.6-pro Go $60 · Plus $120 │ +│ mimo-v2.6-flash Go $60 · Plus $120 │ │ │ │ Session spend │ -│ mimo-v2.6-pro · $0.15/M $1.44 │ +│ mimo-v2.6-flash · $0.14 / $0.28 per 1M $1.44 │ │ │ │ Available Zen balance ⓘ Ready │ │ Zen balance is ready and will take over │ @@ -114,7 +114,7 @@ Go usage Healthy ● 5-hour 0% used · resets in 4h 58m 100% left ● Weekly 28% used · resets in 3d 2h 72% left ● Monthly 14% used · resets Nov 7, 8:55 AM 86% left -Monthly allowance mimo-v2.6-pro · Go $60 · Plus $120 +Monthly allowance mimo-v2.6-flash · Go $60 · Plus $120 OpenCode publishes only a percentage per window and no balance endpoint, so "left" is the inverse of the same percentage rather than a dollar figure; the plan tier cannot be probed either. ``` diff --git a/docs/quota-meter.zh-CN.md b/docs/quota-meter.zh-CN.md index d72ffe1d..ef13cd89 100644 --- a/docs/quota-meter.zh-CN.md +++ b/docs/quota-meter.zh-CN.md @@ -41,10 +41,10 @@ composer 输入框右侧的小控件。它只回答一个问题:**你当前所 │ ──────────────────────────────────────────── │ │ │ │ 月度额度 │ -│ mimo-v2.6-pro Go $60 · Plus $120 │ +│ mimo-v2.6-flash Go $60 · Plus $120 │ │ │ │ 当前会话消耗 │ -│ mimo-v2.6-pro · $0.15/M $1.44 │ +│ mimo-v2.6-flash · $0.14 / $0.28 per 1M $1.44 │ │ │ │ 可用 Zen 余额 ⓘ Ready │ │ Zen 余额已就绪,将在额度用尽时自动承接 │ @@ -113,7 +113,7 @@ Go 用量 状态正常 ● 5 小时 0% 已用 · 4小时58分钟后重置 100% 剩余 ● 每周 28% 已用 · 3天2小时后重置 72% 剩余 ● 每月 14% 已用 · 11月7日8点55分重置 86% 剩余 -月度额度 mimo-v2.6-pro · Go $60 · Plus $120 +月度额度 mimo-v2.6-flash · Go $60 · Plus $120 OpenCode 每个窗口只提供百分比、没有余额接口,所以「剩余」是同一百分比的反面,而不是美元数;套餐档位也无法探测。 ``` diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 164c30e3..e0172efe 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -10,10 +10,12 @@ A **plane** is one gateway base URL with its own catalog and its own credential. | Plane | Route id | Base URL | Credential | Models | | :-- | :-- | :-- | :-- | --: | -| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | `OPENCODE_API_KEY` | 80 served / 130 listed | +| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | `OPENCODE_API_KEY` | 81 served / 131 listed | | Go (subscription) | `opencode-go` | the `endpoint` family's | `OPENCODE_GO_API_KEY` | 30 served / 37 listed | -**There is no longer one base URL per plane to quote.** The gateway answers on two endpoint families and the `endpoint` setting picks one: `auto` follows the catalog, which today names `…/inference/openai/v1` and `…/inference/go/openai/v1`, and `legacy` pins the older `…/zen/v1` and `…/zen/go/v1`. Both are still served and they are entitled separately, which is why the choice is the user's rather than a migration. The dated measurement below was taken on the legacy family. +Counts as of 2026-11-03 (the shipped shim, regenerated in #132 and refreshed for `pulse-alpha-free`); the document's 2026-10-09 stamp is for its measurements. + +**There is no longer one base URL per plane to quote.** The gateway answers on two endpoint families and the `endpoint` setting picks one: `auto`, the default, is an alias of `modern`, the per-protocol inference family (`…/inference/openai/v1` and `…/inference/go/openai/v1` for completions), and `legacy` pins the older `…/zen/v1` and `…/zen/go/v1`. Both are still served and they are entitled separately, which is why the choice is the user's rather than a migration. The dated measurement below was taken on the legacy family. A plane is a property of the **account and the base URL**, never of a model. The same model id can exist on both planes and be served on a different shape in each. @@ -21,21 +23,21 @@ A plane is a property of the **account and the base URL**, never of a model. The ## 2. Shapes -A **shape** is one wire protocol, named by the SDK a model declares in the catalog (`provider.npm`). A model naming no SDK speaks its route's own protocol, which is OpenAI Chat Completions. +A **shape** is one wire protocol, named by the SDK a model declares in the catalog (`provider.npm`). A model naming no SDK speaks its route's own protocol, which is OpenAI Chat Completions. Counts as of 2026-11-03 (the shipped shim, regenerated in #132 and refreshed for `pulse-alpha-free`). | SDK in the catalog | Protocol | Zen | Go | | :------------------ | :---------------------- | --: | ----: | -| _(none)_ | `openai-completions` | 23 | 19 | +| _(none)_ | `openai-completions` | 24 | 19 | | `@ai-sdk/openai` | `openai-responses` | 31 | **5** | | `@ai-sdk/anthropic` | `anthropic-messages` | 17 | **5** | | `@ai-sdk/mistral` | `mistral-conversations` | 1 | — | | `@ai-sdk/google` | `google-generative-ai` | 8 | **1** | -**Absence is the signal, and it is a real one.** Both planes set a provider-level `npm` of `@ai-sdk/openai-compatible` in `models.dev`, and **no model declares it explicitly**: 0 of 80 on Zen, 0 of 30 on Go. A model that speaks the completions shape simply omits `provider.npm` and inherits it. So "no SDK" does not mean "unknown"; it means _the provider default_, which is the protocol the route was itself registered with. +**Absence is the signal, and it is a real one.** Both planes set a provider-level `npm` of `@ai-sdk/openai-compatible` in `models.dev`, and **no model declares it explicitly**: 0 of 81 on Zen, 0 of 30 on Go. A model that speaks the completions shape simply omits `provider.npm` and inherits it. So "no SDK" does not mean "unknown"; it means _the provider default_, which is the protocol the route was itself registered with. That is why the servability check treats `undefined` as servable, and why `@ai-sdk/openai-compatible` is deliberately **not** in `PROTOCOL_FOR_SDK`: listing it would record the default as though it were an override. The coverage guard still covers the case where a model declares it explicitly. None do today, and if one did, the test would name it rather than drop the model. -**The two bold cells were the gap, and they are closed.** The Go plane serves 12 models on shapes other than completions; both planes now have a route for every shape either one carries. What made them work was making the plane part of a route's definition, below. +**The two bold cells were the gap, and they are closed.** The Go plane serves 11 models on shapes other than completions; both planes now have a route for every shape either one carries. What made them work was making the plane part of a route's definition, below. ## 3. Routes diff --git a/docs/testing.md b/docs/testing.md index c9d8c373..2eb3b58f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -21,6 +21,12 @@ The bulk of the suite. Each file names one subject, and the cases are the branch The rule for what belongs here, from `AGENTS.md`: **a predicate that lives in a closure cannot be exercised alone**. Every decision the plugin makes is either exported or takes its value as a parameter, and `test/architecture.test.ts` polices the purity of `src/utils/` so the ones that should be pure stay pure. +Three properties the unit and mock suites keep by construction rather than by care: + +- **Offline.** `test/support/offline.ts` refuses every non-loopback request, so a networked runner cannot make a result depend on the vendor's live catalog. A case that means to fetch stubs `fetch` itself. +- **Order-independent.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test, and CI runs the suite shuffled (`pnpm run test:shuffle`; reproduce a failure with `--sequence.seed=`). A new module-level store gets a reset beside its siblings. +- **Engine-checked.** The suite refuses to start below `engines.node` and names both versions (`test/support/node-engine.ts`), because below the floor `node:sqlite` and the ICU strings the tests pin differ, and the failures read like regressions. + ### Mock: the wiring, without a vendor `test/mock/` boots the plugin against a gateway that answers like the real one, including its refusals (`403 FreeTierError`, `400 ModelProtocolUnsupported`, `401 AuthError`). This is the layer that catches a wrong header, a wrong path or a wrong protocol — the questions no unit test can ask because they are about the seam. @@ -31,20 +37,23 @@ The rule for what belongs here, from `AGENTS.md`: **a predicate that lives in a **Three rules, learned from failures:** -1. **A live case skips on a vendor-side move, it does not fail.** A retired model (`410 ModelDeprecated`), a closed tier (`FreeTierError`), a missing entitlement and a busy gateway (`429`) are all "not observable today". The reason is printed so the run says which. -2. **A case that walks candidates walks past the retired ones.** A `410` is an answer, so a picker that accepts "anything but a refusal" picks a dead model and then fails on it. +1. **A live case skips on a vendor-side move, it does not fail.** A retired model (`410 ModelDeprecated`, or an upstream's `400 … Model is unavailable`), a closed tier (`FreeTierError`), a missing entitlement and a busy gateway (`429`) are all "not observable today". The reason is printed so the run says which. +2. **A case that walks candidates walks past the retired ones.** A `410` is an answer, so a picker that accepts "anything but a refusal" picks a dead model and then fails on it. For a `400` it is the body, not the status, that makes it a retirement: a bare `400` is also a malformed request, which is the plugin's fault and must still fail (`test/e2e/verdict.ts`, `retiredAtTheGateway`). 3. **An assertion about the vendor is a claim about a moving thing.** Prefer asserting the PLUGIN's behaviour (which endpoint it chose, which headers it sent) over the vendor's answer. ## The fixtures -`test/test-helpers.ts` — streams, a capture fetch, a mock cordis context, the OpenCode store fixture, element walkers for the React trees. +`test/test-helpers.ts` — streams, a capture fetch, a mock cordis context, element walkers for the React trees. The jsdom (client) tests import it, so nothing in it may need a host-only module. + +`test/support/opencode-store-fixture.ts` — the OpenCode store fixture. It needs `node:sqlite` and the filesystem, which is why it lives with the other host-only support files rather than in the shared helpers. -`test/fixtures/catalogs.ts` — **synthetic catalogue rows**. Every other fixture quotes something that exists; this one builds the row a test needs, including rows the vendor has not produced. It exists because the plane-aware decisions are only observable when the two planes DISAGREE, and the real catalogue agrees about the free flag for every model it shares. +`test/support/catalogs.ts` — **synthetic catalogue rows**, reaching `src` through the one door like the other fixtures. Every other fixture quotes something that exists; this one builds the row a test needs, including rows the vendor has not produced. It exists because the plane-aware decisions are only observable when the two planes DISAGREE, and the real catalogue agrees about the free flag for every model it shares. -Two entry points: +Three entry points: - `spec(...)` / `divergent(id, { go, zen })` build a row (or a pair of rows) directly, for a decision that takes the row as its parameter — `coreToolDecision`, `calculateTurnCost`. -- `payload({ go, zen })` builds a whole `models.dev` payload, for the paths that read the LIVE catalogue through `refreshCatalog` (`test/plane-lookup.test.ts`). That world is per FILE: `refreshCatalog` merges by id and cannot remove, so a file that installs one holds nothing else. +- `payload({ go, zen })` builds a whole `models.dev` payload, for the paths that read the LIVE catalogue through `refreshCatalog` (`test/plane-lookup.test.ts`). That world is per TEST: `refreshCatalog` merges by id and cannot remove, but the setup file resets the live catalog after every test, so a file installs its world in `beforeEach` and gives its rows ids no other test names. +- `seedDivergentCatalog()` installs one such world for the request-path tests: `ZEN_FREE_PROBE` is free on Zen and paid on Go, `GO_FREE_PROBE` the reverse, so a lookup that reads the wrong plane is red in both directions (`test/tool-fallback.test.ts`, `test/fetch-patch.test.ts`). **A model with no price in a fixture is PAID at a nominal rate, not free.** The parser derives `is_free` from a zero cost, so a "no price" default would silently turn every fixture model into a free tier. @@ -55,16 +64,17 @@ A gate is a script, and a script that runs at import time cannot be tested — s | script | the decision, extracted | what the test holds | | :-- | :-- | :-- | | `generate-go-limits.ts` | `scripts/go-limits-html.ts` | which table is an allowance (by header, not position), the refusal when the page carries none, and the round trip through the real parser | -| `check-vendor-version.ts` | `scripts/vendor-version.ts` | the constant is found, and "read nothing" is distinguishable from "passed" | -| `publish-scoped.ts` | `scripts/wrapper-forwarder.ts` | the retired alias's entry LINKS against the built barrel in a real Node process | +| `check-vendor-version.ts` | `scripts/vendor-version.ts` | the constant is found, "read nothing" is distinguishable from "passed", and the scan reads every file a copy can live in but never the generated tables | +| `report-host-drift.ts` | `scripts/host-drift.ts` | what each channel installs today, read as npm resolves it (`dsh` at the record's dist-tag; cordis, the loader, schemastery and pi at the newest version the bridge's ranges admit, a prerelease only on its own tuple), one line per field that moved, and "the registry answered nothing" never read as current; the CLI runs against a loopback registry and the CI job's condition is evaluated (nightly and dispatch only) | +| `use-channel.ts` | `scripts/channel-manifest.ts` | which packages a channel repins (every `@deepseek-ai` pin moved, pi's range untouched, a harness package with no rule refused rather than guessed), the identity on the committed channel, the one-line floor override, and the host-free lockfile a `--relock` starts from (every host key gone from the importer, `packages:` and `snapshots:`, everything else kept) | -The pattern is the same one the `utils/` gate enforces inside `src/`, applied to the one place it had not reached: **a decision that lives in a top-level-await script is exercised only by the vendor's schedule**. Two of the three above had already passed while wrong — the version gate twice, the Go-limits reader when the docs moved to HTML. +The pattern is the same one the `utils/` gate enforces inside `src/`, applied to the one place it had not reached: **a decision that lives in a top-level-await script is exercised only by the vendor's schedule** (or, for the channel switch, by the next harness bump). The first two had already passed while wrong — the version gate twice, the Go-limits reader when the docs moved to HTML. ## Two habits ### Plant the violation -A test is only worth its name if it fails when the behaviour it describes is removed. Every guard in this repository was checked by putting the old behaviour back and watching the test go red — the plane-blind lookup, the wrong warning token, the enrichment that answered a refusal with a catalogue, the wrapper's `default` re-export. +A test is only worth its name if it fails when the behaviour it describes is removed. Every guard in this repository was checked by putting the old behaviour back and watching the test go red — the plane-blind lookup, the wrong warning token, the enrichment that answered a refusal with a catalogue. The recipe: copy the file, apply the old behaviour, run the one test file, confirm the failure names the behaviour (not something incidental), restore. @@ -74,7 +84,6 @@ A fixture that quietly stopped describing what the test believes is the failure - `test/catalog.test.ts` measures that the two planes **disagree about price** and **agree about the free flag** — the second is what makes the first fixture necessary, and it fails the day the vendor breaks it, naming the models. - `test/plane-lookup.test.ts` asserts that the synthetic payload **really did** produce a free Go row and a paid Zen row before any decision is exercised. -- `test/wrapper-forwarder.test.ts` links the generated wrapper against the **built** barrel in a real Node process, because a bundler turns a missing export into `undefined` and stays green. ## What the gates cover, and what they do not diff --git a/package.json b/package.json index 6eb0f7ff..1781e025 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "./client": { "default": "./lib/client.js" }, - "./src/*": "./src/*", "./package.json": "./package.json", "./cordis.patch.yml": "./cordis.patch.yml", "./locale/*.json": "./locale/*.json" @@ -83,6 +82,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", @@ -94,11 +94,16 @@ }, "devDependencies": { "@deepseek-ai/cordis": "4.0.6-alpha.1", + "@deepseek-ai/cordis-plugin-loader": "1.0.7-alpha.1", + "@deepseek-ai/dsh-app-boot": "0.2.2-alpha.2", "@deepseek-ai/dsh-client-store": "0.2.2-alpha.2", "@deepseek-ai/dsh-client-ui-primitives": "0.2.2-alpha.2", + "@deepseek-ai/dsh-config-editor": "0.2.2-alpha.2", + "@deepseek-ai/dsh-llm": "0.2.2-alpha.2", "@deepseek-ai/dsh-llm-pi-ai": "0.2.2-alpha.2", + "@deepseek-ai/dsh-settings": "0.2.2-alpha.2", "@deepseek-ai/dsh-typert-protocol": "0.2.2-alpha.2", - "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-ai": "^1.1.0", "@testing-library/dom": "^10.4.2", "@testing-library/react": "^16.3.3", "@types/node": "^26.6.6", @@ -106,6 +111,7 @@ "@types/react-dom": "18.3.7", "@types/semver": "^7.8.0", "@vitest/coverage-v8": "5.0.5", + "es-module-lexer": "2.3.2", "jsdom": "^30.1.3", "react": "18.3.1", "react-dom": "18.3.1", @@ -113,7 +119,8 @@ "typescript": "^7.0.2", "ultracite": "^8.1.0", "vite-plus": "catalog:", - "vitest": "5.0.5" + "vitest": "5.0.5", + "yaml": "2.9.1" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.4 || >=4.0.5-0 <4.1.0 || >=4.0.6-0 <4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 519a9eab..9e54220f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,22 +181,37 @@ importers: devDependencies: '@deepseek-ai/cordis': specifier: 4.0.6-alpha.1 - version: 4.0.6-alpha.1 + version: 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/cordis-plugin-loader': + specifier: 1.0.7-alpha.1 + version: 1.0.7-alpha.1(@deepseek-ai/cordis@4.0.6-alpha.1)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/dsh-app-boot': + specifier: 0.2.2-alpha.2 + version: 0.2.2-alpha.2(@deepseek-ai/cordis-plugin-group@1.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-home-paths@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-launch-environment@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)) '@deepseek-ai/dsh-client-store': specifier: 0.2.2-alpha.2 version: 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-client-ui-primitives': specifier: 0.2.2-alpha.2 version: 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/dsh-config-editor': + specifier: 0.2.2-alpha.2 + version: 0.2.2-alpha.2(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-app-boot@0.2.2-alpha.2(@deepseek-ai/cordis-plugin-group@1.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-home-paths@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-launch-environment@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))) + '@deepseek-ai/dsh-llm': + specifier: 0.2.2-alpha.2 + version: 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-llm-pi-ai': specifier: 0.2.2-alpha.2 - version: 0.2.2-alpha.2(8b3c4de9e35394a0b1eaa9d4a24ad799) + version: 0.2.2-alpha.2(aef934cbe440d955a5b163695f06493a) + '@deepseek-ai/dsh-settings': + specifier: 0.2.2-alpha.2 + version: 0.2.2-alpha.2(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-app-boot@0.2.2-alpha.2(@deepseek-ai/cordis-plugin-group@1.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-home-paths@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-launch-environment@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)))(@deepseek-ai/dsh-brand@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-session@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-scope@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)))(@deepseek-ai/schemastery@3.18.6-alpha.1)(node-addon-require-builtin@0.1.9) '@deepseek-ai/dsh-typert-protocol': specifier: 0.2.2-alpha.2 version: 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@earendil-works/pi-ai': - specifier: 1.4.0 - version: 1.4.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5) + specifier: ^1.1.0 + version: 1.5.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5) '@testing-library/dom': specifier: ^10.4.2 version: 10.4.2 @@ -218,6 +233,9 @@ importers: '@vitest/coverage-v8': specifier: 5.0.5 version: 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5) + es-module-lexer: + specifier: 2.3.2 + version: 2.3.2 jsdom: specifier: ^30.1.3 version: 30.1.3 @@ -242,6 +260,9 @@ importers: vitest: specifier: 5.0.5 version: 5.0.5(@types/node@26.6.6)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.6)(typescript@7.0.2)(yaml@2.9.1))(jsdom@30.1.3) + yaml: + specifier: 2.9.1 + version: 2.9.1 packages: @@ -422,6 +443,27 @@ packages: resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} engines: {node: '>=20.19.0'} + '@deepseek-ai/cordis-plugin-group@1.0.6-alpha.1': + resolution: {integrity: sha512-7Pi3rbKv2dTRwI+JI2EJQPJ3bhX5rfBww0tZJ1NT+KZHDef8SL23aISVQ80WSub83ltu4O5sLM3wJf9uf1CWhw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.6-alpha.1 + '@deepseek-ai/cordis-plugin-loader': ~1.0.7-alpha.1 + + '@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1': + resolution: {integrity: sha512-sw7+Wn84GxxeH58+nSNp9UplMxqY3uhjNN7Bs3Q87twLAaLgmYMN+Nk7ryE6riX1N85K+Ao0H8Z5OOH4euCAMg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.6-alpha.1 + '@deepseek-ai/cordis-plugin-loader': ~1.0.7-alpha.1 + + '@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1': + resolution: {integrity: sha512-Xbx+LkoUjShfFZTwzG2CY5/Uw1lkuf7sXcj1Y39G1sRjye/FAVn/JoZ5E7pPW2oU70Zt/n/4beV+OROKkMvuiw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.6-alpha.1 + node-addon-require-builtin: ^0.1.9 + peerDependenciesMeta: + node-addon-require-builtin: + optional: true + '@deepseek-ai/cordis@4.0.6-alpha.1': resolution: {integrity: sha512-GgUFYPkAERAhjq/H7ASM9G2tWu07bY6XOD/q16jUXqsj/arawh0EIZKB+PjTXLjC7vgG3XptSvJ/8dZkkIWsfQ==} hasBin: true @@ -437,6 +479,21 @@ packages: '@deepseek-ai/cosmokit@1.8.7-alpha.1': resolution: {integrity: sha512-+R+9sx+SJbkyeidV3s+fvgsqCMZAfx2Z00YCQNhc5RsTen1SnvIYWM8neQah/1SxjlsXRo9jxX9U58Wk68JjvA==} + '@deepseek-ai/dsh-app-boot@0.2.2-alpha.2': + resolution: {integrity: sha512-GyQFybO4zW5M7n4PRFduRkqSX/T4RTPf5g6a73CKjxw/sl1w+VTSVwPvTMHWjnRgnc4wjQZ4TaS/1XPJ9gD6NQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.6-alpha.1 + '@deepseek-ai/cordis-plugin-group': ~1.0.6-alpha.1 + '@deepseek-ai/cordis-plugin-include': ~1.0.11-alpha.1 + '@deepseek-ai/cordis-plugin-loader': ~1.0.7-alpha.1 + '@deepseek-ai/dsh-home-paths': 0.2.2-alpha.2 + '@deepseek-ai/dsh-launch-environment': 0.2.2-alpha.2 + + '@deepseek-ai/dsh-atomic-write@0.2.2-alpha.2': + resolution: {integrity: sha512-HbWIFGHYO1FMa60iXJiaLC0Lg9yr4pm3g+9kfaJ1kmZk2fOZyVRyn09xvC3j9zVczQXHx4zmPGgbRoRxyZV1Ug==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.6-alpha.1 + '@deepseek-ai/dsh-attachment@0.2.2-alpha.2': resolution: {integrity: sha512-fl0hnNBtHHYpxN+X0WnR/+aGFq0tOWDQMImyDhJX4CA60OIlgReSPAn+VPrHtKkqK3h5t/4g2yMcsukX/0kehg==} peerDependencies: @@ -465,6 +522,18 @@ packages: peerDependencies: '@deepseek-ai/cordis': ~4.0.6-alpha.1 + '@deepseek-ai/dsh-config-editor@0.2.2-alpha.2': + resolution: {integrity: sha512-15xhR6IlrQ4BT1N+oT639zPj9QCepsPUOMA+KHmYlOoSs84qYEeI3Uj/UcNFQ6P5L9DJTzfy0BtN7cXeUoo7lg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.6-alpha.1 + '@deepseek-ai/cordis-plugin-include': ~1.0.11-alpha.1 + '@deepseek-ai/cordis-plugin-loader': ~1.0.7-alpha.1 + '@deepseek-ai/dsh-app-boot': 0.2.2-alpha.2 + '@deepseek-ai/dsh-hmr': 0.2.2-alpha.2 + peerDependenciesMeta: + '@deepseek-ai/dsh-hmr': + optional: true + '@deepseek-ai/dsh-credentials@0.2.2-alpha.2': resolution: {integrity: sha512-2CBJncX9FvDNfXQOkaA4p/FoliK/6clNzsfQ9oB73f4s9d5NWYC2EARY8iB4BBn8KMtz6XDRC5d9vnDjIhtY5Q==} peerDependencies: @@ -478,6 +547,11 @@ packages: '@deepseek-ai/dsh-llm': 0.2.2-alpha.2 '@deepseek-ai/dsh-sandbox': 0.2.2-alpha.2 + '@deepseek-ai/dsh-home-paths@0.2.2-alpha.2': + resolution: {integrity: sha512-Csxrpp2CFmdPWG3UYGsrGmE11GYpo/iuQuPHg/8ydPfykvhjC4F3BM6WSmYlrBrSE9SKEXVNyY32E2Ec3dHklg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.6-alpha.1 + '@deepseek-ai/dsh-launch-environment@0.2.2-alpha.2': resolution: {integrity: sha512-T6LWWiZyirehZ4UrNTJPYEdtXcQWF5EMhx6hwFhI7/RTuVIY3mgKhq42fyIQrB5x5s5J83MdVfCeoGqQgcPVYA==} peerDependencies: @@ -504,6 +578,16 @@ packages: peerDependencies: '@deepseek-ai/cordis': ~4.0.6-alpha.1 + '@deepseek-ai/dsh-official-bundles@0.2.2-alpha.2': + resolution: {integrity: sha512-1ZzPosfWZqq3gjySzmHCV4msx8znD1JmZx1Xc9nHFCqkimRFfLsiA2VdvZXgXenlfmamBg2y+HXPCRmhoEtmRg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.6-alpha.1 + + '@deepseek-ai/dsh-package-manifest@0.2.2-alpha.2': + resolution: {integrity: sha512-3+FJWR/YptEzUAYPjNJb9P90xX6+MPMT3KmHtQScNuJNi+6gWLw2b/0/xjHGfG11hZyIB9AqSgY1dUIb25x6mA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.6-alpha.1 + '@deepseek-ai/dsh-sandbox@0.2.2-alpha.2': resolution: {integrity: sha512-GXgvCmabIQlnk55kn2Hs4eHGf9o2Nh62VumASxJ3AeYtHq6dE/cr15BvJXG5HqAQy0ZH++gby4fnkTSRiKEfTg==} peerDependencies: @@ -522,6 +606,14 @@ packages: '@deepseek-ai/cordis': ~4.0.6-alpha.1 '@deepseek-ai/dsh-scope': 0.2.2-alpha.2 + '@deepseek-ai/dsh-settings@0.2.2-alpha.2': + resolution: {integrity: sha512-t1ZxV83EJa2xDO7Wlr+ZNEb3O7HnYh859dDxARHlaQX7yoVM2YGm89SYpa6qzh2X9+0/ZUl2q5DqsZRCNxbnIg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.6-alpha.1 + '@deepseek-ai/dsh-brand': 0.2.2-alpha.2 + '@deepseek-ai/dsh-session': 0.2.2-alpha.2 + '@deepseek-ai/schemastery': ~3.18.6-alpha.1 + '@deepseek-ai/dsh-timeout@0.2.2-alpha.2': resolution: {integrity: sha512-Rn5+DsqeUpytiSIOiN8Rdw6vAvMBG8CZTG8W2l0zXstHt0w0UiFQxv6UI21uFHjWL2fUVPvP6KLamv+nE72nmw==} peerDependencies: @@ -545,15 +637,23 @@ packages: '@deepseek-ai/schemastery@3.18.6-alpha.1': resolution: {integrity: sha512-ID5DKpWa0BnrcNdZ3ofWkhOoRLQ3MsFr1FdRVHBiALeE9egaCm4QatuCvQK0Cd/haRBcEvefacz2JEYYMFAEnw==} - '@earendil-works/pi-ai@1.4.0': - resolution: {integrity: sha512-hQM/C4+1w8rHYACzDDf+0vQMnEnpzIop1tptIERVgR3ZeT/hHSzBNG8PaWPgxWQjqSeKCyBZSyp8uTT6eKK1pQ==} + '@earendil-works/pi-ai@1.5.0': + resolution: {integrity: sha512-VeWxNqTHSqRCf8AcY/6kvNwh+neRL6MPxZPePVztIQy6qqK9MR/YoCSN/ZgDnIyCD3pqEXgRyQbzk4KV4uvjbg==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-telemetry@1.4.0': - resolution: {integrity: sha512-MLIfowwUYIY65jSjMX82paZOrX/NTvLk6D08h+3Jq42a99RzUOewzuCR0zOlPul3D6UBfddat76VT6LvsHEs1w==} + '@earendil-works/pi-schema@1.5.0': + resolution: {integrity: sha512-RW0MbT2tR65OY6xMsgB/yVBb7m/RTNwA+9hurSN1vVXfHZRVmVnXbruBPrx/PzbrQnW5yLVlMRWxErGDQtF1Hg==} engines: {node: '>=22.19.0'} + '@earendil-works/pi-telemetry@1.5.0': + resolution: {integrity: sha512-VW3tcVdnNfl/Vk8wHPTzZdKkxcsCOM12ppiO4DXnd9wMxUCmOfLt/wvUnWeKWbTVlVSRg0uqKtnnhE2UCsG2Bw==} + engines: {node: '>=22.19.0'} + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@exodus/bytes@1.16.0': resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -919,6 +1019,7 @@ packages: '@smithy/core@3.35.2': resolution: {integrity: sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==} engines: {node: '>=18.0.0'} + deprecated: Deprecated due to a bug in custom logger data redaction. See https://github.com/aws/aws-sdk-js-v3/issues/8393. '@smithy/credential-provider-imds@4.5.2': resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} @@ -1381,6 +1482,9 @@ packages: resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} engines: {node: '>= 20'} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1401,6 +1505,9 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -1529,6 +1636,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1542,6 +1652,9 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -1672,6 +1785,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + jsdom@30.1.3: resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -1688,6 +1805,9 @@ packages: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -1826,6 +1946,72 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-addon-native-custom-loader@0.1.9: + resolution: {integrity: sha512-Zh87aubSc1UF8CzZsRws1KtSwXiPDH0tbcg8V/2OA8Tcq9yGJRESFcEOC5NGLFama5IGfC3aJhubZbrj9Un2qA==} + engines: {node: '>=20'} + + node-addon-require-builtin-darwin-arm64@0.1.9: + resolution: {integrity: sha512-n45xEILQVOLZ2hVhzRXesTMYS4odHquyI/B/IkPMPUCONa8PjNfBag6lYBH4wDrhgpLNNBwMsUD8d8O/13QPPA==} + engines: {node: '>=20'} + cpu: [arm64] + os: [darwin] + + node-addon-require-builtin-darwin-x64@0.1.9: + resolution: {integrity: sha512-Sm4Liqh1uo9zgJB2F/pACPPZdHu+fj9Qniyr1Q99dDQB0v71VgZgkAgv3K0/E1iPQBlZq9rakFkY7MGUIfWc0A==} + engines: {node: '>=20'} + cpu: [x64] + os: [darwin] + + node-addon-require-builtin-linux-arm64-gnu@0.1.9: + resolution: {integrity: sha512-ToU4wGtOD/069i2y4fJ3cjDJiiWtcjSQTZMf0YKlotb5BmTM7FjhR3X1aub8J/YjlOwl6665J/XAYuvCaDO5sA==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + node-addon-require-builtin-linux-arm64-musl@0.1.9: + resolution: {integrity: sha512-85Z5Avp++wfa9iiTIH8LC/r6qvlC/CiwpifiwIz0gYQ9PzajTSkz06gX7YDqf1oUqWYxU9uwNEOmo7mktHOOfA==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + node-addon-require-builtin-linux-x64-gnu@0.1.9: + resolution: {integrity: sha512-fh3K3/JvBfSYwU8vEOpp4Wdp4N/jX0EnZYq5GsaftlQin1FVqgve/yDNoh+asxBMOQyHOG8F4dXlV1WCierrfg==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + node-addon-require-builtin-linux-x64-musl@0.1.9: + resolution: {integrity: sha512-B6fu/5xmbRxnifM7nWz0IESkYnUl8dwFZmRDMsxdhzv2nb+EtBRFAXg4lf73qhX3yoZD4SnWJILBQMlIAwBCzw==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + libc: [musl] + + node-addon-require-builtin-win32-arm64-msvc@0.1.9: + resolution: {integrity: sha512-FOQuzNe8B1BANu886rJgdbQuRxPGUQmewkykw87wSr32U0ibQ3AWGuUM/Gh7LG4RekjFaifj7gEI/tq2+6GU6g==} + engines: {node: '>=20'} + cpu: [arm64] + os: [win32] + + node-addon-require-builtin-win32-ia32-msvc@0.1.9: + resolution: {integrity: sha512-N29P0kmIBT6hnkNNFYzxn+o9ydT6OYZe9RFAi8dSxV1rNSul1FlxtuKWYe8w2sQpbQGYuGDEtpOwdeFtq2iFTg==} + engines: {node: '>=20 <23'} + cpu: [ia32] + os: [win32] + + node-addon-require-builtin-win32-x64-msvc@0.1.9: + resolution: {integrity: sha512-+i6N4A7m7txjbw8pDYESdWXiKO9EEVq0SzTzwPS0WZuV4F1MzQnaDJwJJnO7T6zAc0BvqHXBOkpharCrUjFhbQ==} + engines: {node: '>=20'} + cpu: [x64] + os: [win32] + + node-addon-require-builtin@0.1.9: + resolution: {integrity: sha512-BCxXCMZDs/VLLh64SwKNw/me7Qx1I7Be4qxWwYVRRm8tJoOB8fL7qg6CmXhSXeUqsKt72t8RaKCX4EN+5nocVg==} + engines: {node: '>=20'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -2120,9 +2306,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typebox@1.3.27: - resolution: {integrity: sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==} - typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -2571,55 +2754,114 @@ snapshots: '@csstools/css-tokenizer@4.0.2': {} - '@deepseek-ai/cordis@4.0.6-alpha.1': + '@deepseek-ai/cordis-plugin-group@1.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/cordis-plugin-loader': 1.0.7-alpha.1(@deepseek-ai/cordis@4.0.6-alpha.1)(node-addon-require-builtin@0.1.9) + + '@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/cordis-plugin-loader': 1.0.7-alpha.1(@deepseek-ai/cordis@4.0.6-alpha.1)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/cosmokit': 1.8.7-alpha.1 + js-yaml: 4.3.2 + + '@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1(@deepseek-ai/cordis@4.0.6-alpha.1)(node-addon-require-builtin@0.1.9)': + dependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/cosmokit': 1.8.7-alpha.1 + '@deepseek-ai/schemastery': 3.18.6-alpha.1 + optionalDependencies: + node-addon-require-builtin: 0.1.9 + + '@deepseek-ai/cordis@4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)': dependencies: '@deepseek-ai/cosmokit': 1.8.7-alpha.1 '@standard-schema/spec': 1.1.0 + optionalDependencies: + '@deepseek-ai/cordis-plugin-include': 1.0.11-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/cordis-plugin-loader': 1.0.7-alpha.1(@deepseek-ai/cordis@4.0.6-alpha.1)(node-addon-require-builtin@0.1.9) '@deepseek-ai/cosmokit@1.8.7-alpha.1': {} + '@deepseek-ai/dsh-app-boot@0.2.2-alpha.2(@deepseek-ai/cordis-plugin-group@1.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-home-paths@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-launch-environment@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/cordis-plugin-group': 1.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/cordis-plugin-include': 1.0.11-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/cordis-plugin-loader': 1.0.7-alpha.1(@deepseek-ai/cordis@4.0.6-alpha.1)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/dsh-atomic-write': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/dsh-home-paths': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/dsh-launch-environment': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/dsh-official-bundles': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/dsh-package-manifest': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + '@eslint-community/regexpp': 4.12.2 + ajv: 8.20.0 + js-yaml: 4.3.2 + node-addon-require-builtin: 0.1.9 + resolve.exports: 2.0.3 + semver: 7.8.5 + + '@deepseek-ai/dsh-atomic-write@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/dsh-attachment@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-brand@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-brand': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-authorization@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-credentials@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-llm@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-credentials': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-llm': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-brand@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-client-store@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-client-ui-primitives@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + + '@deepseek-ai/dsh-config-editor@0.2.2-alpha.2(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-app-boot@0.2.2-alpha.2(@deepseek-ai/cordis-plugin-group@1.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-home-paths@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-launch-environment@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)))': + dependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/cordis-plugin-include': 1.0.11-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/cordis-plugin-loader': 1.0.7-alpha.1(@deepseek-ai/cordis@4.0.6-alpha.1)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/dsh-app-boot': 0.2.2-alpha.2(@deepseek-ai/cordis-plugin-group@1.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-home-paths@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-launch-environment@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)) + '@deepseek-ai/dsh-atomic-write': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + js-yaml: 4.3.2 + yaml: 2.9.1 '@deepseek-ai/dsh-credentials@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-brand': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-fs@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-brand@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-llm@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-sandbox@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-llm@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-session@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-scope@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))))': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-brand': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-llm': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-sandbox': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-llm@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-session@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-scope@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))) + '@deepseek-ai/dsh-home-paths@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/dsh-launch-environment@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) - '@deepseek-ai/dsh-llm-pi-ai@0.2.2-alpha.2(8b3c4de9e35394a0b1eaa9d4a24ad799)': + '@deepseek-ai/dsh-llm-pi-ai@0.2.2-alpha.2(aef934cbe440d955a5b163695f06493a)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-attachment': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-brand@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)) '@deepseek-ai/dsh-authorization': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-credentials@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-llm@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)) '@deepseek-ai/dsh-brand': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) @@ -2630,7 +2872,9 @@ snapshots: '@deepseek-ai/dsh-timeout': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-util-values': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/schemastery': 3.18.6-alpha.1 - '@earendil-works/pi-ai': 1.4.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5) + '@earendil-works/pi-ai': 1.5.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5) + optionalDependencies: + '@deepseek-ai/cordis-plugin-loader': 1.0.7-alpha.1(@deepseek-ai/cordis@4.0.6-alpha.1)(node-addon-require-builtin@0.1.9) transitivePeerDependencies: - '@aws-sdk/credential-provider-node' - '@modelcontextprotocol/sdk' @@ -2646,7 +2890,7 @@ snapshots: '@deepseek-ai/dsh-llm@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-brand': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-timeout': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-typert-protocol': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) @@ -2655,60 +2899,86 @@ snapshots: '@deepseek-ai/schemastery': 3.18.6-alpha.1 zod: 4.4.3 + '@deepseek-ai/dsh-official-bundles@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/dsh-package-manifest': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + + '@deepseek-ai/dsh-package-manifest@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/dsh-sandbox@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-llm@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-session@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-scope@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)))': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-llm': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-session': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-scope@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)) '@deepseek-ai/dsh-util-values': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-scope@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-session@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-scope@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-brand': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-llm': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-scope': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) '@deepseek-ai/dsh-util-values': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/dsh-settings@0.2.2-alpha.2(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-app-boot@0.2.2-alpha.2(@deepseek-ai/cordis-plugin-group@1.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-home-paths@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-launch-environment@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)))(@deepseek-ai/dsh-brand@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-session@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-scope@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)))(@deepseek-ai/schemastery@3.18.6-alpha.1)(node-addon-require-builtin@0.1.9)': + dependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) + '@deepseek-ai/cordis-plugin-loader': 1.0.7-alpha.1(@deepseek-ai/cordis@4.0.6-alpha.1)(node-addon-require-builtin@0.1.9) + '@deepseek-ai/cosmokit': 1.8.7-alpha.1 + '@deepseek-ai/dsh-brand': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/dsh-config-editor': 0.2.2-alpha.2(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-app-boot@0.2.2-alpha.2(@deepseek-ai/cordis-plugin-group@1.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-home-paths@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))(@deepseek-ai/dsh-launch-environment@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1))) + '@deepseek-ai/dsh-session': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)(@deepseek-ai/dsh-scope@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)) + '@deepseek-ai/dsh-util-values': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) + '@deepseek-ai/schemastery': 3.18.6-alpha.1 + yaml: 2.9.1 + transitivePeerDependencies: + - '@deepseek-ai/cordis-plugin-include' + - '@deepseek-ai/dsh-app-boot' + - '@deepseek-ai/dsh-hmr' + - node-addon-require-builtin + '@deepseek-ai/dsh-timeout@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-typert-protocol@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-brand': 0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1) zod: 4.6.5 '@deepseek-ai/dsh-util-crypto@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/dsh-util-values@0.2.2-alpha.2(@deepseek-ai/cordis@4.0.6-alpha.1)': dependencies: - '@deepseek-ai/cordis': 4.0.6-alpha.1 + '@deepseek-ai/cordis': 4.0.6-alpha.1(@deepseek-ai/cordis-plugin-include@1.0.11-alpha.1)(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1) '@deepseek-ai/schemastery@3.18.6-alpha.1': dependencies: '@deepseek-ai/cosmokit': 1.8.7-alpha.1 '@standard-schema/spec': 1.1.0 - '@earendil-works/pi-ai@1.4.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5)': + '@earendil-works/pi-ai@1.5.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5)': dependencies: '@anthropic-ai/sdk': 0.129.0(zod@4.6.5) '@aws-sdk/client-bedrock-runtime': 3.1127.0 - '@earendil-works/pi-telemetry': 1.4.0 + '@earendil-works/pi-schema': 1.5.0 + '@earendil-works/pi-telemetry': 1.5.0 '@google/genai': 2.21.0 '@smithy/node-http-handler': 4.12.1 http-proxy-agent: 9.1.0 https-proxy-agent: 9.1.0 openai: 7.19.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@8.12.2)(ws@8.22.0)(zod@4.6.5) partial-json: 0.1.7 - typebox: 1.3.27 transitivePeerDependencies: - '@aws-sdk/credential-provider-node' - '@modelcontextprotocol/sdk' @@ -2722,7 +2992,11 @@ snapshots: - ws - zod - '@earendil-works/pi-telemetry@1.4.0': {} + '@earendil-works/pi-schema@1.5.0': {} + + '@earendil-works/pi-telemetry@1.5.0': {} + + '@eslint-community/regexpp@4.12.2': {} '@exodus/bytes@1.16.0': {} @@ -3251,6 +3525,13 @@ snapshots: agent-base@9.0.0: {} + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -3263,6 +3544,8 @@ snapshots: ansi-styles@6.2.3: {} + argparse@2.0.1: {} + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -3375,6 +3658,8 @@ snapshots: extend@3.0.2: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3391,6 +3676,8 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 + fast-uri@3.1.9: {} + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 @@ -3530,6 +3817,10 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + jsdom@30.1.3: dependencies: '@asamuzakjp/css-color': 7.1.3 @@ -3564,6 +3855,8 @@ snapshots: '@babel/runtime': 7.29.7 ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} + jsonc-parser@3.3.1: {} jwa@2.0.1: @@ -3679,6 +3972,67 @@ snapshots: nanoid@3.3.20: {} + node-addon-native-custom-loader@0.1.9: {} + + node-addon-require-builtin-darwin-arm64@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-darwin-x64@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-linux-arm64-gnu@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-linux-arm64-musl@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-linux-x64-gnu@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-linux-x64-musl@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-win32-arm64-msvc@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-win32-ia32-msvc@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin-win32-x64-msvc@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optional: true + + node-addon-require-builtin@0.1.9: + dependencies: + node-addon-native-custom-loader: 0.1.9 + optionalDependencies: + node-addon-require-builtin-darwin-arm64: 0.1.9 + node-addon-require-builtin-darwin-x64: 0.1.9 + node-addon-require-builtin-linux-arm64-gnu: 0.1.9 + node-addon-require-builtin-linux-arm64-musl: 0.1.9 + node-addon-require-builtin-linux-x64-gnu: 0.1.9 + node-addon-require-builtin-linux-x64-musl: 0.1.9 + node-addon-require-builtin-win32-arm64-msvc: 0.1.9 + node-addon-require-builtin-win32-ia32-msvc: 0.1.9 + node-addon-require-builtin-win32-x64-msvc: 0.1.9 + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -3952,8 +4306,6 @@ snapshots: tslib@2.8.1: {} - typebox@1.3.27: {} - typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 01f8e58c..4036d6f0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ -# Neither is used at runtime here. They arrive as dependencies of -# @earendil-works/pi-ai, which this repo only imports in a test that reads its -# protocol table, so their build scripts stay off rather than being approved. +# Both arrive as dependencies of @earendil-works/pi-ai, which the bridge loads at +# runtime from the host and the suite imports directly. Neither package's build +# script is needed, so their builds stay off rather than being approved. allowBuilds: "@google/genai": false protobufjs: false @@ -36,6 +36,21 @@ minimumReleaseAgeExclude: - ultracite@8.1.0 - "@deepseek-ai/dsh-client-store@0.2.1-rc.4 || 0.2.2-alpha.2" - "@deepseek-ai/dsh-client-ui-primitives@0.2.1-rc.4 || 0.2.2-alpha.2" +# +# No pi override and no pi patch (D14''). The pi this install runs is what an +# npm user of the channel runs: unpatched, at the newest 1.x the bridge's range +# admits, pinned by the committed lockfile (this one for alpha, +# `channels/rc/pnpm-lock.yaml` for rc). `scripts/use-channel.ts` switches the +# dev install between the channels, and to the range's floor (the harness's own +# pin) without committing it; `test/support/host-versions.ts` names both. +# +# `pnpm run`/`pnpm exec` only WARN when the install looks stale against these +# files, rather than reinstalling: a channel switch restores the committed +# files while the tree keeps the switched install, and an automatic reinstall +# would put the default channel back under a run that was meant to test the +# other one. The suite itself checks which host is installed +# (`test/host-versions.test.ts`), so a stale tree fails there, by name. +verifyDepsBeforeRun: warn overrides: vite@*: "catalog:" peerDependencyRules: diff --git a/scripts/channel-manifest.ts b/scripts/channel-manifest.ts new file mode 100644 index 00000000..c0d8f13d --- /dev/null +++ b/scripts/channel-manifest.ts @@ -0,0 +1,150 @@ +/** + * The pure half of `scripts/use-channel.ts`: the manifest and workspace text a + * channel's install reads, as functions of the committed text and the + * channel's record (`test/support/host-versions.ts`). No I/O, so every rule is + * a unit test (`test/use-channel.test.ts`). + * + * @module scripts/channel-manifest + */ +import { parseAllDocuments } from "yaml"; + +/** The fields of a channel record the manifest rewrite reads. */ +export interface ChannelPins { + readonly cordis: string; + readonly dsh: string; + readonly loader: string; + readonly schemastery: string; +} + +const PI = "@earendil-works/pi-ai"; + +/** + * The version a channel pins one `@deepseek-ai/*` package to, or undefined for + * a package no rule covers (the caller refuses it rather than guessing). + */ +export const pinFor = (name: string, pins: ChannelPins): string | undefined => { + // The plugin's own runtime pin moves with the channel too, so a channel's + // lockfile holds one coherent cluster. On rc that means the install runs the + // host's schemastery line, not the one the published manifest pins. + if (name === "@deepseek-ai/schemastery") { + return pins.schemastery; + } + if (name === "@deepseek-ai/cordis") { + return pins.cordis; + } + if (name === "@deepseek-ai/cordis-plugin-loader") { + return pins.loader; + } + return name.startsWith("@deepseek-ai/dsh-") ? pins.dsh : undefined; +}; + +interface Manifest { + [key: string]: unknown; + dependencies?: Record; + devDependencies?: Record; +} + +const repin = ( + section: Record | undefined, + pins: ChannelPins +): Record | undefined => { + if (section === undefined) { + return undefined; + } + return Object.fromEntries( + Object.entries(section).map(([name, spec]) => { + if (!name.startsWith("@deepseek-ai/")) { + return [name, spec]; + } + const pin = pinFor(name, pins); + if (pin === undefined) { + throw new Error( + `${name} has no channel rule in scripts/channel-manifest.ts pinFor; add one` + ); + } + return [name, pin]; + }) + ); +}; + +/** + * `package.json` as the channel installs it: every `@deepseek-ai/*` + * dependency on the channel's pin, everything else (pi's range included) + * untouched. Key order and the trailing newline are kept, so the alpha + * record applied to the committed manifest is the committed manifest. + */ +export const channelManifest = (text: string, pins: ChannelPins): string => { + const manifest = JSON.parse(text) as Manifest; + const next: Manifest = { ...manifest }; + const dependencies = repin(manifest.dependencies, pins); + const devDependencies = repin(manifest.devDependencies, pins); + if (dependencies !== undefined) { + next.dependencies = dependencies; + } + if (devDependencies !== undefined) { + next.devDependencies = devDependencies; + } + return `${JSON.stringify(next, null, 2)}\n`; +}; + +/** + * `pnpm-workspace.yaml` with every pi in the tree held to `version`: one + * override line under the existing `overrides:` key. Refuses a workspace that + * already overrides pi (the floor would be ambiguous) or has no single + * `overrides:` key to extend. + */ +export const workspaceWithPi = (text: string, version: string): string => { + if (text.includes(`"${PI}":`)) { + throw new Error(`pnpm-workspace.yaml already overrides ${PI}`); + } + const key = /^overrides:\n/gmu; + const found = text.match(key) ?? []; + if (found.length !== 1) { + throw new Error( + `pnpm-workspace.yaml needs exactly one top-level overrides: key, found ${found.length}` + ); + } + return text.replaceAll(key, `overrides:\n "${PI}": "${version}"\n`); +}; + +const HOST_SCOPE = "@deepseek-ai/"; + +/** A lockfile key or importer name that belongs to the harness cluster. */ +const isHostKey = (key: unknown): boolean => + typeof key === "string" && + (key.startsWith(HOST_SCOPE) || key.startsWith(`'${HOST_SCOPE}`)); + +/** + * The lockfile a `--relock` starts from: the given one with every + * `@deepseek-ai/*` entry removed (the root importer's, and every `packages:` + * and `snapshots:` key), so pnpm resolves the whole cluster afresh for the + * channel's pins, peers included, while every other package keeps the + * version it already has. Starting from the alpha lockfile as it stands would + * keep alpha's optional peers (`cordis-plugin-include`, `-group`) and their + * dependencies where a fresh rc install resolves the stable lines. + */ +export const withoutHostPackages = (text: string): string => + parseAllDocuments(text) + .map((doc) => { + const sections: (readonly string[])[] = [ + ["importers", ".", "dependencies"], + ["importers", ".", "devDependencies"], + ["importers", ".", "optionalDependencies"], + ["packages"], + ["snapshots"], + ]; + for (const section of sections) { + const map = doc.getIn(section) as + | { items?: { key: { value?: unknown } }[] } + | undefined; + // Collected first: deleting from the map while walking it skips items. + const doomed = (map?.items ?? []) + .map((item) => item.key.value) + .filter((key) => isHostKey(key)); + for (const key of doomed) { + doc.deleteIn([...section, key]); + } + } + return doc.toString({ lineWidth: 0 }); + }) + .join(""); diff --git a/scripts/check-vendor-version.ts b/scripts/check-vendor-version.ts index 534bce9f..86fbc4e5 100644 --- a/scripts/check-vendor-version.ts +++ b/scripts/check-vendor-version.ts @@ -23,6 +23,16 @@ * The second version only scanned `opencode/`, which meant a bare copy * was invisible to it: it passed while three e2e comments were two patches * stale. + * + * `--copies` runs the second check alone and stops before the registry. That + * half is offline and reads only files the repository maintains (the generated + * tables, which hold vendor data, are skipped), so only the repository can + * break it, and CI gates every pull request on it. The first half follows the + * vendor, and gates only a pull request that touches the pin or this script + * (`scripts/freshness-scope.ts` decides). + * + * A failure prints one `error: ` line first (the detail after it), so a + * CI step that only warns can quote the cause rather than guess it. */ import { readdir, readFile } from "node:fs/promises"; @@ -32,11 +42,11 @@ import { CLI_LITERAL as LITERAL, disagreements, pinnedVersion, + scansFile, } from "./vendor-version.ts"; -/** Where a copy may appear. Extensions are listed because the scan is recursive. */ +/** Where a copy may appear; `scansFile` decides which files under them are read. */ const ROOTS = ["src", "test", "docs", "scripts", "README.md"]; -const EXTENSIONS = /\.(?:ts|tsx|md|json|ya?ml)$/; const CONSTANT_FILE = "src/identity.ts"; @@ -56,7 +66,7 @@ const literals = async (pattern: RegExp): Promise> => { } } catch { // Not a directory, so read it as a file when it is one we scan. - if (EXTENSIONS.test(path)) { + if (scansFile(path)) { record(path, await readFile(path, "utf8")); } } @@ -87,6 +97,12 @@ const main = async (): Promise => { `these copies do not say ${pinned}, which is what ${CONSTANT_FILE} declares:\n${detail}` ); } + if (process.argv.includes("--copies")) { + process.stdout.write( + `ok ${[...copies.values()].flat().length} copy/copies and ${[...mentions.values()].flat().length} bare mention/mentions say ${pinned}, as ${CONSTANT_FILE} does (registry not asked)\n` + ); + return; + } const response = await fetch("https://registry.npmjs.org/opencode-ai/latest"); if (!response.ok) { @@ -109,4 +125,13 @@ const main = async (): Promise => { ); }; -await main(); +try { + await main(); +} catch (error) { + // The cause, not a stack: every failure above is a sentence written for the + // reader, and the line a warning step quotes. + process.stderr.write( + `error: ${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; +} diff --git a/scripts/check.ts b/scripts/check.ts index 868f0bd5..25d8139b 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -24,6 +24,12 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { en as cardCopy } from "../src/settings-copy.ts"; +import { + exportsOutsideFiles, + publishListMismatch, + releasePendingTargets, +} from "./package-checks.ts"; +import { SCOPED_TARGETS } from "./scoped-targets.ts"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const failures: string[] = []; @@ -458,6 +464,43 @@ if (releaseYml.includes("scripts/publish-scoped.ts")) { ); } +/* ----------------- 10b. what exports names ships, and what ships is checked */ + +// An `exports` target outside `files` resolves in this repository and nowhere +// else: `./src/*` was exported for releases while `src/` was never packed. +const outside = exportsOutsideFiles(pkg); +if (outside.length === 0) { + ok("every exports target is inside files"); +} else { + for (const target of outside) { + fail( + `exports names ${target}, which files does not pack; a consumer resolving ` + + `${target} from the tarball gets ENOENT` + ); + } +} + +// The verify step polls its own list. A name publish-scoped.ts publishes and +// the step never polls goes unchecked; a name it polls and nothing publishes +// fails every release. +const pending = releasePendingTargets(releaseYml); +if (pending === undefined) { + fail( + 'release.yml must carry exactly one pending="…" verify line (none, or more than one, found); the publish list cannot be checked' + ); +} else { + const mismatch = publishListMismatch([pkg.name, ...SCOPED_TARGETS], pending); + if (mismatch.length === 0) { + ok( + `release.yml verifies exactly what is published (${pending.join(", ")})` + ); + } else { + for (const message of mismatch) { + fail(message); + } + } +} + /* ------------------------------------------ 11. the client stays lean */ /* @@ -546,11 +589,23 @@ if (releaseYml.includes("scripts/publish-scoped.ts")) { * validates the reference before it can reach a credential lookup. The client * measured 132541 bytes against the old line. * + * The same 128 -> 132 KB raise was made, independently, when the + * store-inspection row's remote descriptor landed: the row had been calling a + * namespace the client never mounted, and the fix is the descriptor plus the + * whitelist parser its codec runs. Source, not a dependency — the client + * measured 132320 bytes against the old line, about 1.2 KB over, which is this + * guard's "raise it" case. + * + * Raised 132 -> 136 KB when those two raises met in one merge: each had been + * measured against a tree without the other, and together the client measured + * 135604 bytes, about 0.4 KB over. Nothing new arrived with the merge — it is + * the same two surfaces, counted once each. + * * That is the kind of growth the last four raises were: a surface the HOST * answers, not a dependency that should have stayed external — which is the case * this tripwire exists to catch, and which the ceiling is not raised for. */ -const CLIENT_BUDGET = 132 * 1024; +const CLIENT_BUDGET = 136 * 1024; const clientPath = join(ROOT, "lib/client.js"); if (existsSync(clientPath)) { const clientStat = statSync(clientPath); diff --git a/scripts/freshness-scope.ts b/scripts/freshness-scope.ts new file mode 100644 index 00000000..63dc6541 --- /dev/null +++ b/scripts/freshness-scope.ts @@ -0,0 +1,318 @@ +/** + * Which freshness checks a pull request is GATED on, and which it only REPORTS. + * + * The three freshness checks in CI's `catalog` job (`catalog:shim`, + * `limits:shim`, `cli:version`) compare something checked in against something + * the vendor publishes, and the vendor moves most days. A check that fails + * every open pull request on each move blames the pull request for the + * vendor's change, and a red that is never the reader's fault is one the + * reader learns to skip. + * + * What the gate exists for is an OMISSION: someone changes a generator (or the + * parser it calls) and forgets to regenerate. That is a fact about the pull + * request's own diff, so the diff decides: a check GATES when the diff touches + * one of its INPUTS, and REPORTS (a warning, exit 0) otherwise. + * + * The inputs are code, never output. A check's inputs are its script and every + * local module the script loads, transitively, read from the sources rather + * than listed by hand - so adding an import to a generator moves its inputs + * without anyone remembering to. The edges are the ones Node would load: types + * erased by Node's own stripper, specifiers read by a real lexer, so a + * type-only import is no edge and a string that only looks like an import is + * none either (`importSpecifiers`). The two generated files are never inputs: a + * pull request that only carries a refresh must report drift, not fail on it. + * A pull request that edits a generated file without touching what generates + * it is still named, in a warning (`handEditedOutputs`), because a hand-edit + * is forbidden (AGENTS.md) and should stay visible. + * + * Everything exported is a function of its arguments - the file reader is a + * parameter - so the decision is testable without a filesystem or a diff. The + * CLI at the bottom is the effect half: + * + * ```sh + * git diff --name-only HEAD^1 HEAD \ + * | node --experimental-strip-types --disable-warning=ExperimentalWarning \ + * scripts/freshness-scope.ts catalog:shim + * # prints "gate" or "report" on stdout; any hand-edit warning goes to stderr + * ``` + * + * No network: it reads the changed-file list and the repository's sources. + */ + +import { readFileSync, realpathSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +import { dirname, join, normalize, resolve } from "node:path/posix"; +import { fileURLToPath } from "node:url"; + +import { initSync, parse } from "es-module-lexer"; + +/** The three checks, by the `package.json` script that runs each. */ +export type FreshnessCheck = "catalog:shim" | "cli:version" | "limits:shim"; + +/** What the diff decides for one check. */ +export type FreshnessScope = "gate" | "report"; + +/** One check: the script it runs, inputs it reads as data, and what it generates. */ +export interface FreshnessCheckSpec { + /** The script the check runs; the import walk starts here. */ + readonly entry: string; + /** + * Inputs the script reads as data rather than importing, so the import walk + * cannot see them. `check-vendor-version.ts` reads `src/identity.ts` as text + * and takes the pinned version out of it with `vendor-version.ts`'s + * `PINNED_VERSION` regular expression. + */ + readonly dataInputs: readonly string[]; + /** The generated files the check compares, and its `--write` rewrites. */ + readonly outputs: readonly string[]; +} + +/** + * Every generated file. Never an input, whichever import reaches it. + * `scripts/vendor-version.ts` keeps its own copy of this list + * (`GENERATED_TABLES`, which `scansFile` skips in the copy scan, since these + * hold vendor data, not copies the repository maintains) rather than importing + * it, which would make this module an input of `cli:version`; + * `test/freshness-scope.test.ts` ties the two. + */ +export const GENERATED_OUTPUTS: readonly string[] = [ + "src/catalog-data.ts", + "src/go-limits-data.ts", +]; + +export const FRESHNESS_CHECKS: Readonly< + Record +> = { + "catalog:shim": { + dataInputs: [], + entry: "scripts/regenerate-catalog-shim.ts", + outputs: ["src/catalog-data.ts"], + }, + "cli:version": { + dataInputs: ["src/identity.ts"], + entry: "scripts/check-vendor-version.ts", + outputs: [], + }, + "limits:shim": { + dataInputs: [], + entry: "scripts/generate-go-limits.ts", + outputs: ["src/go-limits-data.ts"], + }, +}; + +export const isFreshnessCheck = (value: string): value is FreshnessCheck => + Object.hasOwn(FRESHNESS_CHECKS, value); + +/** + * Every module specifier one source LOADS, as Node would load it: the source's + * types erased by Node's own stripper (`stripTypeScriptTypes`, the same + * erasure `--experimental-strip-types` applies when the generator runs), and + * the specifiers read off the result by a real lexer (`es-module-lexer`), not + * by a pattern. + * + * Both halves matter. The stripper decides what is type-only: a whole-statement + * `import type …` or `export type … from` is erased, so the module is never + * loaded and cannot change what the generator emits (`test/architecture.test.ts` + * skips these edges for the client graph on the same ground); an inline + * `import { type A }` strips to `import {}`, which still loads the module, so + * it IS an edge. The lexer knows strings, templates, regular expressions and + * comments: a `"docs/*"` string or a `/\/*$/u` regex cannot open a fake + * comment that hides the import after it, and an import written inside a + * template is text, not an edge. A hand-written pattern got both wrong, and a + * missed edge is the dangerous direction - it turns a gate into a report. + * + * Throws on a source Node itself could not load in strip mode (an `enum`, a + * `namespace`): every input runs under that flag, so such a file would break + * the generator first, and a throw fails the scope step closed rather than + * reporting a walk that stopped early. A type error in a skipped file is + * `pnpm run check`'s to catch, not freshness's. A dynamic `import()` whose + * argument is not a string literal names no file and is skipped. Only module + * source belongs here: `inputsOf` never hands it a JSON file or other data a + * generator imports, and names the file when this throws. + */ +export const importSpecifiers = (source: string): string[] => { + initSync(); + const [imports] = parse(stripTypeScriptTypes(source)); + return [ + ...new Set( + imports.flatMap((entry) => (entry.n === undefined ? [] : [entry.n])) + ), + ]; +}; + +/** + * The extensions Node parses as JavaScript or TypeScript modules. Any other + * local file a generator imports (`../package.json` with a JSON import + * attribute) is data: an input, but a leaf, never lexed. + */ +const MODULE_EXTENSION = /\.(?:[cm]?[jt]s|tsx)$/u; + +/** Reads one repository-relative path, or answers `undefined` when there is no such file. */ +export type SourceReader = (path: string) => string | undefined; + +/** + * The repository-relative file a LOCAL specifier names, from the importing + * file's own directory - or `undefined` for a package, a built-in, or a + * specifier that names no file in the tree. + */ +export const resolveLocalImport = ( + importer: string, + specifier: string, + read: SourceReader +): string | undefined => { + if (!specifier.startsWith("./") && !specifier.startsWith("../")) { + return undefined; + } + const base = normalize(join(dirname(importer), specifier)); + const candidates = [base, `${base}.ts`, `${base}.tsx`, `${base}/index.ts`]; + return candidates.find((candidate) => read(candidate) !== undefined); +}; + +/** + * A check's inputs: its entry, every local module the entry reaches through + * its imports, and its data inputs - minus the generated files, which are + * neither walked nor counted. A reached file that is not module source (a + * JSON import) counts as an input and is not walked further. Sorted, + * repository-relative. + * + * Throws when the entry cannot be read: an empty walk would answer "touches + * nothing", which reports a change that should have gated. Throws, naming the + * file, when a reached module cannot be stripped (`importSpecifiers`). + */ +export const inputsOf = ( + check: FreshnessCheck, + read: SourceReader +): string[] => { + const spec = FRESHNESS_CHECKS[check]; + if (read(spec.entry) === undefined) { + throw new Error( + `${check}: cannot read ${spec.entry} - the walk read nothing, which is not the same as no inputs` + ); + } + const generated = new Set(GENERATED_OUTPUTS); + const seen = new Set(); + const pending = [spec.entry]; + while (pending.length > 0) { + const file = pending.pop(); + if (file === undefined || seen.has(file) || generated.has(file)) { + continue; + } + seen.add(file); + if (!MODULE_EXTENSION.test(file)) { + continue; + } + let specifiers: string[]; + try { + specifiers = importSpecifiers(read(file) ?? ""); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${file}: ${message}`, { cause: error }); + } + for (const specifier of specifiers) { + const target = resolveLocalImport(file, specifier, read); + if (target !== undefined) { + pending.push(target); + } + } + } + for (const data of spec.dataInputs) { + if (!generated.has(data)) { + seen.add(data); + } + } + return [...seen].toSorted(); +}; + +/** One changed-file list, as `git diff --name-only` prints it. */ +export const parseChangedFiles = (text: string): string[] => + text + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line !== ""); + +/** GATE when the diff touches an input, REPORT otherwise. */ +export const scopeFor = ( + changedFiles: readonly string[], + inputs: readonly string[] +): FreshnessScope => { + const wanted = new Set(inputs); + return changedFiles.some((file) => wanted.has(file)) ? "gate" : "report"; +}; + +/** + * The check's generated files the diff changed while touching none of its + * inputs - a refresh, or a hand-edit; either way worth naming. Empty when the + * check gates: a generator change is expected to carry its regenerated output. + */ +export const handEditedOutputs = ( + check: FreshnessCheck, + changedFiles: readonly string[], + inputs: readonly string[] +): string[] => { + if (scopeFor(changedFiles, inputs) === "gate") { + return []; + } + const changed = new Set(changedFiles); + return FRESHNESS_CHECKS[check].outputs.filter((output) => + changed.has(output) + ); +}; + +/** The GitHub annotation that names one such file. */ +export const handEditWarning = ( + check: FreshnessCheck, + output: string +): string => + `::warning file=${output}::${output} changed, but nothing that generates it did (${FRESHNESS_CHECKS[check].entry} and its imports). It is generated: a refresh carries it, a hand-edit is forbidden (AGENTS.md). Regenerate with 'pnpm run ${check} -- --write' if this was not one.`; + +/** Reads a repository-relative path under `root`, or `undefined`. */ +const fileReader = + (root: string): SourceReader => + (path: string): string | undefined => { + try { + return readFileSync(resolve(root, path), "utf8"); + } catch { + return undefined; + } + }; + +const main = (): void => { + const check = process.argv[2] ?? ""; + if (!isFreshnessCheck(check)) { + process.stderr.write( + `usage: freshness-scope.ts <${Object.keys(FRESHNESS_CHECKS).join("|")}> < changed-files\n` + ); + process.exit(2); + } + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const changed = parseChangedFiles(readFileSync(0, "utf8")); + const inputs = inputsOf(check, fileReader(root)); + for (const output of handEditedOutputs(check, changed, inputs)) { + process.stderr.write(`${handEditWarning(check, output)}\n`); + } + process.stdout.write(`${scopeFor(changed, inputs)}\n`); +}; + +/** The real path of `path`, or `undefined` when there is none to resolve. */ +const realPath = (path: string | undefined): string | undefined => { + if (path === undefined) { + return undefined; + } + try { + return realpathSync(path); + } catch { + return undefined; + } +}; + +// Run only as a script, never on import (the tests import the functions above). +// Both sides are real paths: Node realpaths the main module's URL but only +// resolves `argv[1]`, so a plain comparison misses an absolute invocation +// through a symlink, and the CLI would print nothing and exit 0. +const invoked = realPath(process.argv[1]); +if ( + invoked !== undefined && + invoked === realPath(fileURLToPath(import.meta.url)) +) { + main(); +} diff --git a/scripts/generate-go-limits.ts b/scripts/generate-go-limits.ts index 9aad8f63..9ba69715 100644 --- a/scripts/generate-go-limits.ts +++ b/scripts/generate-go-limits.ts @@ -14,7 +14,10 @@ * A hand-typed table would therefore be stale within a release — and a stale * dollar figure is worse than none, because it looks authoritative. So the table * is generated from `https://opencode.ai/docs/go.md` (the page's own markdown - * source, served as `text/plain`) and CI fails when it drifts. + * source, served as `text/plain`), and CI checks it for drift: a pull request + * that changes this generator (or a module it loads) fails on a stale table; + * any other pull request, and a push to main, warns, and the nightly run + * proposes the refresh. * * **That URL is gone.** Measured 2026-10-12: it answers 404, the docs live under * `/v2/docs/console/go/` now, and the new page serves HTML - the allowance figures diff --git a/scripts/host-drift.ts b/scripts/host-drift.ts new file mode 100644 index 00000000..29c13a2a --- /dev/null +++ b/scripts/host-drift.ts @@ -0,0 +1,168 @@ +/** + * The pure half of `scripts/report-host-drift.ts`: what the registry says each + * harness channel installs today, against the channel records in + * `test/support/host-versions.ts` (D22''). + * + * A record names what an npm install of the channel resolves, so drift is + * read the way npm resolves it, never off a package's own dist-tags: + * + * - `dsh` is `@deepseek-ai/dsh` at the record's `distTag`; + * - the bridge (`@deepseek-ai/dsh-llm-pi-ai`, one version with the cluster) + * declares the pi range and the cordis, loader and schemastery ranges, and + * each of those is the newest published version the range admits (a + * prerelease only where the range names one on the same tuple, as npm + * does); + * - `piFloor` is the pi range's own floor. + * + * Every function here is a function of its arguments - the registry's answers + * are data handed in - so `test/host-drift.test.ts` holds every rule with known + * answers. The CLI fetches the packuments and prints one warning per line. + * + * @module scripts/host-drift + */ +import semver from "semver"; + +/** The record fields the comparison reads (a subset of `ChannelRecord`). */ +export interface DriftRecord { + readonly cordis: string; + readonly distTag: string; + readonly dsh: string; + readonly loader: string; + readonly pi: string; + readonly piFloor: string; + readonly piRange: string; + readonly schemastery: string; +} + +/** The slice of an npm packument (abbreviated or full) the comparison reads. */ +export interface Packument { + readonly "dist-tags"?: Readonly>; + readonly versions?: Readonly< + Record< + string, + { + readonly dependencies?: Readonly>; + readonly peerDependencies?: Readonly>; + } + > + >; +} + +/** The packages the CLI fetches, by the role each plays in a record. */ +export const REGISTRY_PACKAGES = { + bridge: "@deepseek-ai/dsh-llm-pi-ai", + cordis: "@deepseek-ai/cordis", + dsh: "@deepseek-ai/dsh", + loader: "@deepseek-ai/cordis-plugin-loader", + pi: "@earendil-works/pi-ai", + schemastery: "@deepseek-ai/schemastery", +} as const; + +export type RegistryRole = keyof typeof REGISTRY_PACKAGES; + +/** One packument per role: what the registry answered. */ +export type RegistryView = Readonly>; + +/** The roles whose version follows a range the bridge declares. */ +const RESOLVED: readonly ("cordis" | "loader" | "schemastery")[] = [ + "cordis", + "loader", + "schemastery", +]; + +/** The newest published version `range` admits, as npm resolves it. */ +export const newestIn = ( + packument: Packument, + range: string +): string | undefined => + semver.maxSatisfying(Object.keys(packument.versions ?? {}), range) ?? + undefined; + +/** A range's floor: the lowest version it could ever admit. */ +export const floorOf = (range: string): string | undefined => + semver.validRange(range) === null + ? undefined + : (semver.minVersion(range)?.version ?? undefined); + +/** + * One line per field where the registry and a channel's record disagree, each + * naming the channel, the field, the record's value and the registry's. An + * empty list means every record is current. A registry answer that cannot be + * read (no dist-tag, no bridge at that version, no declared range, nothing in + * the range) is a line too: "read nothing" must never look like "current". + */ +export const hostDrift = ( + records: Readonly>, + registry: RegistryView +): string[] => { + const lines: string[] = []; + for (const [channel, record] of Object.entries(records)) { + const say = (field: string, recorded: string, found: string): void => { + lines.push( + `${channel}: ${field} is ${found} on the registry; the record says ${recorded}` + ); + }; + const tagged = registry.dsh["dist-tags"]?.[record.distTag]; + if (tagged === undefined) { + lines.push( + `${channel}: ${REGISTRY_PACKAGES.dsh} has no '${record.distTag}' dist-tag; nothing compared` + ); + continue; + } + if (tagged !== record.dsh) { + say(`${REGISTRY_PACKAGES.dsh}@${record.distTag}`, record.dsh, tagged); + } + const bridge = registry.bridge.versions?.[tagged]; + if (bridge === undefined) { + lines.push( + `${channel}: ${REGISTRY_PACKAGES.bridge}@${tagged} is not published; the ranges were not compared` + ); + continue; + } + const declared = { + ...bridge.peerDependencies, + ...bridge.dependencies, + } as Readonly>; + + const piRange = declared[REGISTRY_PACKAGES.pi]; + if (piRange === undefined) { + lines.push( + `${channel}: ${REGISTRY_PACKAGES.bridge}@${tagged} declares no ${REGISTRY_PACKAGES.pi}` + ); + } else { + if (piRange !== record.piRange) { + say("the bridge's pi range", record.piRange, piRange); + } + const floor = floorOf(piRange); + if (floor !== undefined && floor !== record.piFloor) { + say("the pi range's floor", record.piFloor, floor); + } + const pi = newestIn(registry.pi, piRange); + if (pi === undefined) { + lines.push( + `${channel}: no published ${REGISTRY_PACKAGES.pi} satisfies ${piRange}` + ); + } else if (pi !== record.pi) { + say(`the newest pi ${piRange} resolves`, record.pi, pi); + } + } + + for (const role of RESOLVED) { + const name = REGISTRY_PACKAGES[role]; + const range = declared[name]; + if (range === undefined) { + lines.push( + `${channel}: ${REGISTRY_PACKAGES.bridge}@${tagged} declares no ${name}` + ); + continue; + } + const found = newestIn(registry[role], range); + if (found === undefined) { + lines.push(`${channel}: no published ${name} satisfies ${range}`); + } else if (found !== record[role]) { + say(`${name} (${range})`, record[role], found); + } + } + } + return lines; +}; diff --git a/scripts/package-checks.ts b/scripts/package-checks.ts new file mode 100644 index 00000000..acabdce5 --- /dev/null +++ b/scripts/package-checks.ts @@ -0,0 +1,140 @@ +/** + * Pure checks over the package's publish shape, shared by `scripts/check.ts` + * and `test/package-checks.test.ts`. + * + * Every function here is a function of its arguments — the caller reads + * `package.json` and `release.yml`, and these decide — so the test can prove + * each answer on a fixture whose answer is known before it trusts the answer + * on the real tree. + * + * @module scripts/package-checks + */ + +/** + * Every string target of an `exports` value, in declaration order: a bare + * string, a subpath map and nested condition objects all flatten to strings. + * + * @param exports - `package.json`'s `exports`, as parsed. + * @returns the targets, such as `./lib/index.mjs`. + */ +export const exportTargets = (exports: unknown): string[] => { + if (typeof exports === "string") { + return [exports]; + } + if (exports === null || typeof exports !== "object") { + return []; + } + return Object.values(exports).flatMap((value: unknown) => + exportTargets(value) + ); +}; + +/** A `files` glob as a whole-path pattern: `*` stays inside one segment. */ +const globPattern = (entry: string): RegExp => + new RegExp( + `^${entry + .split("*") + .map((part) => part.replaceAll(/[.+?^${}()|[\]\\]/gu, String.raw`\$&`)) + .join("[^/]*")}$`, + "u" + ); + +/** + * Whether npm packs `target` under this `files` allowlist. + * + * Deliberately no more of npm than the package uses: an entry equal to the + * target, a directory entry that is a prefix of it (`lib` covers + * `lib/index.mjs`), and a glob entry (`*` within one segment) that matches it — + * or equals it, for an `exports` subpath pattern such as `./locale/*.json`. + * `package.json` is always packed. A target with a `*` that no glob entry + * equals is outside: a subpath pattern resolves to files the allowlist has to + * name the same way. + * + * @param target - an `exports` target, usually `./`-relative. + * @param files - `package.json`'s `files`. + * @returns `true` when the tarball carries the target. + */ +export const coveredByFiles = ( + target: string, + files: readonly string[] +): boolean => { + const path = target.replace(/^\.\//u, ""); + if (path === "package.json") { + return true; + } + return files.some((raw) => { + const entry = raw.replace(/^\.\//u, "").replace(/\/$/u, ""); + if (entry === path) { + return true; + } + if (entry.includes("*")) { + return !path.includes("*") && globPattern(entry).test(path); + } + return !path.includes("*") && path.startsWith(`${entry}/`); + }); +}; + +/** + * The `exports` targets the published tarball does not carry. Each one + * resolves in this repository and fails with ENOENT in every install. + * + * @param pkg - the parsed `package.json`. + * @returns the uncovered targets, in `exports` order. + */ +export const exportsOutsideFiles = (pkg: { + readonly exports?: unknown; + readonly files?: readonly string[]; +}): string[] => + exportTargets(pkg.exports).filter( + (target) => !coveredByFiles(target, pkg.files ?? []) + ); + +/** + * The package names release.yml's verify step polls, read off its + * `pending="name@$VER …"` line. + * + * @param releaseYml - the workflow's text. + * @returns the names with `@$VER` stripped, or `undefined` unless there is + * exactly one such line — absent, or ambiguous because a second list + * elsewhere in the workflow could be the one read. The caller must treat + * `undefined` as a failure, never as an empty list that agrees with nothing. + */ +export const releasePendingTargets = ( + releaseYml: string +): string[] | undefined => { + const lines = [...releaseYml.matchAll(/^\s*pending="([^"]*)"/gmu)]; + const line = lines.length === 1 ? lines[0]?.[1] : undefined; + if (line === undefined) { + return undefined; + } + return line + .split(/\s+/u) + .filter((entry) => entry !== "") + .map((entry) => entry.replace(/@\$VER$/u, "")); +}; + +/** + * Where the published list and the verified list disagree, one message per + * name that is in one and not the other. + * + * @param published - what the release publishes. + * @param verified - what the verify step polls for. + * @returns the messages; empty when the lists hold the same names. + */ +export const publishListMismatch = ( + published: readonly string[], + verified: readonly string[] +): string[] => [ + ...published + .filter((name) => !verified.includes(name)) + .map( + (name) => + `${name} is published but release.yml's verify step never checks it` + ), + ...verified + .filter((name) => !published.includes(name)) + .map( + (name) => + `${name} is verified by release.yml but nothing publishes it, so every release fails on it` + ), +]; diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index 4d1d90a2..6a3a0168 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -4,16 +4,16 @@ * The package ships as `dsh-opencode-patch` — the unscoped name DSH resolves, * the name the docs use, the name release-please versions. * - * It also publishes under: - * - `@viztor/dsh-opencode-patch` — the CANONICAL scoped name. Same tree, same - * bundle, same version as the unscoped one; this is the name new installs - * should use, and the one the client bundle registers its card under - * (`SCOPED_PKG`). - * - `@viztor/dsh-opencode` — RETIRED. A deprecated thin wrapper that ships no - * `lib/client.js` and forwards its row to `dsh-opencode-patch`. It is kept - * publishing only so an existing install keeps resolving; nothing in the - * source tree registers or advertises it any more, which is what stops one - * package answering to two spellings. + * It also publishes under `@viztor/dsh-opencode-patch` — the CANONICAL scoped + * name. Same tree, same bundle, same version as the unscoped one; this is the + * name new installs should use, and the one the client bundle registers its + * card under (`SCOPED_PKG`). The list is `SCOPED_TARGETS` + * (`scripts/scoped-targets.ts`), which `scripts/check.ts` holds in step with + * release.yml's verify step. + * + * The retired name `@viztor/dsh-opencode` is no longer published: its last + * version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the + * final message on the registry. * * Usage: node --experimental-strip-types scripts/publish-scoped.ts * PUBLISH_DRY_RUN=1 node --experimental-strip-types scripts/publish-scoped.ts @@ -21,8 +21,7 @@ * * `PUBLISH_DRY_RUN=1` builds every scratch tree, prints the exact manifest each * alias would publish, and stops before the first network call. The per-alias - * transformations — the thin wrapper in particular — are otherwise only - * observable after a real release. + * transformations are otherwise only observable after a real release. */ import { execFileSync } from "node:child_process"; @@ -37,10 +36,9 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; -import { wrapperForwarder } from "./wrapper-forwarder.ts"; +import { SCOPED_TARGETS } from "./scoped-targets.ts"; const ROOT = path.resolve(import.meta.dirname, ".."); -const SCOPED_TARGETS = ["@viztor/dsh-opencode-patch", "@viztor/dsh-opencode"]; const pkgRaw: unknown = JSON.parse( readFileSync(path.join(ROOT, "package.json"), "utf-8") @@ -121,79 +119,6 @@ for (const target of SCOPED_TARGETS) { 'name: "@viztor/dsh-opencode-patch"' ) ); - } else if (target === "@viztor/dsh-opencode") { - // Point at the CANONICAL name, not the other alias: the message a user - // reads on the registry is the one place that should not offer a choice - // between two spellings of the same package. - manifest.deprecated = - "Renamed to @viztor/dsh-opencode-patch. Please install that instead: https://www.npmjs.com/package/@viztor/dsh-opencode-patch"; - const existingDeps = - (manifest.dependencies as Record | undefined) ?? {}; - manifest.dependencies = { - ...existingDeps, - "dsh-opencode-patch": `^${version}`, - }; - - // Thin wrapper entrypoints that re-export dsh-opencode-patch directly. - // - // **The names the canonical package HAS, and no `default`:** it exports - // none, and re-exporting one is a link error on every load — which is - // what this used to ship. The text comes from one helper so the runtime - // entry and the type declaration cannot drift. - const forwarder = wrapperForwarder(); - writeFileSync(path.join(scratch, "lib", "index.mjs"), `${forwarder}\n`); - writeFileSync(path.join(scratch, "lib", "index.d.mts"), `${forwarder}\n`); - - // The wrapper carries no client half of its own. - // - // Its patch forwards to the `dsh-opencode-patch` row, and that row's own - // package supplies the browser bundle. Shipping a second copy here — the - // manifest's whole-`lib` `files` entry copies it in — would hand the page - // the same bundle twice under the same module ids, mounting the client - // half twice. A ~38 kB duplicate in a package whose stated job is to - // forward two lines is also simply not thin. - const dsh = manifest.dsh as Record | undefined; - if (dsh !== undefined) delete dsh.client; - const wrapperExports = manifest.exports as - | Record - | undefined; - if (wrapperExports !== undefined) delete wrapperExports["./client"]; - rmSync(path.join(scratch, "lib", "client.js"), { force: true }); - - // Forwarding cordis patch to mount dsh-opencode-patch - const forwarderPatch = [ - "# Thin wrapper patch forwarding to dsh-opencode-patch", - "- insert:", - " - id: dsh-opencode-patch", - ' name: "dsh-opencode-patch"', - ].join("\n"); - writeFileSync( - path.join(scratch, "cordis.patch.yml"), - `${forwarderPatch}\n` - ); - - // The page leads with the CANONICAL name. A retired name may appear as the - // reason the reader is here — never as the package's own title, which is - // what made three npm pages look like three packages. - const redirectReadme = [ - "# dsh-opencode-patch", - "", - "> ⚠️ **This npm name is retired.** The package is `dsh-opencode-patch`,", - "> scoped as [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch).", - "", - "You are on the page for the old name `@viztor/dsh-opencode`. It is a **thin compatibility", - "wrapper**: it depends on and re-exports `dsh-opencode-patch` and ships no code of its own.", - "", - "### How to migrate", - "", - "```sh", - '# DSH Web UI (recommended): Settings → Plugins → Install Plugin → "dsh-opencode-patch"', - "", - "# Or from your profile directory:", - "npm install dsh-opencode-patch", - "```", - ].join("\n"); - writeFileSync(path.join(scratch, "README.md"), redirectReadme); } writeFileSync( path.join(scratch, "package.json"), diff --git a/scripts/report-host-drift.ts b/scripts/report-host-drift.ts new file mode 100644 index 00000000..e412faf8 --- /dev/null +++ b/scripts/report-host-drift.ts @@ -0,0 +1,72 @@ +/** + * Report where the harness channels' latest releases have moved past the + * records in `test/support/host-versions.ts` (D22''). + * + * node --experimental-strip-types scripts/report-host-drift.ts [--registry ] + * + * CI runs it nightly and on dispatch. It REPORTS and never gates: each drift is + * one `::warning::` line and the exit code is 0, whatever the registry + * answered, because a channel moving is the harness's release, not a + * regression of this repository. Turning the report into a proposed bump is + * P-T3's; a bump itself follows CONTRIBUTING's release step 3. + * + * The rules are `scripts/host-drift.ts`; this half fetches one abbreviated + * packument per package from the registry (`--registry`, default the public + * one) and prints what the rules answer. A fetch that fails is a warning too, + * so a run that read nothing never prints "current". + * + * @module scripts/report-host-drift + */ +import { CHANNELS } from "../test/support/host-versions.ts"; +import { + hostDrift, + type Packument, + REGISTRY_PACKAGES, + type RegistryRole, + type RegistryView, +} from "./host-drift.ts"; + +const flag = process.argv.indexOf("--registry"); +const registry = ( + flag === -1 ? "https://registry.npmjs.org" : (process.argv[flag + 1] ?? "") +).replace(/\/+$/u, ""); + +const packument = async (name: string): Promise => { + const response = await fetch( + `${registry}/${name.replace("/", "%2f")}`, + // The abbreviated form carries dist-tags and each version's dependency + // ranges, which is all the rules read. + { headers: { accept: "application/vnd.npm.install-v1+json" } } + ); + if (!response.ok) { + throw new Error(`the registry answered ${response.status} for ${name}`); + } + return (await response.json()) as Packument; +}; + +try { + const roles = Object.keys(REGISTRY_PACKAGES) as RegistryRole[]; + const answers = await Promise.all( + roles.map(async (role) => packument(REGISTRY_PACKAGES[role])) + ); + const view = Object.fromEntries( + roles.map((role, index) => [role, answers[index] ?? {}]) + ) as RegistryView; + const lines = hostDrift(CHANNELS, view); + for (const line of lines) { + console.log(`::warning::${line}`); + } + if (lines.length === 0) { + console.log( + `current: ${Object.entries(CHANNELS) + .map( + ([channel, record]) => `${channel} dsh@${record.dsh}, pi ${record.pi}` + ) + .join("; ")}` + ); + } +} catch (error) { + console.log( + `::warning::host drift not checked: ${error instanceof Error ? error.message : String(error)}` + ); +} diff --git a/scripts/scoped-targets.ts b/scripts/scoped-targets.ts new file mode 100644 index 00000000..484c7300 --- /dev/null +++ b/scripts/scoped-targets.ts @@ -0,0 +1,15 @@ +/** + * The scoped aliases `scripts/publish-scoped.ts` publishes the package under. + * + * Release.yml's verify step polls the same names on its `pending="…"` line, and + * `scripts/check.ts` fails when the two lists disagree. The list lives apart + * from `publish-scoped.ts` because that script publishes at import time, so the + * check and its test could not read the list from there. + * + * `@viztor/dsh-opencode` is no longer published (retired in 1.x, D9): its last + * version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the + * final message on the registry. + * + * @module scripts/scoped-targets + */ +export const SCOPED_TARGETS: readonly string[] = ["@viztor/dsh-opencode-patch"]; diff --git a/scripts/use-channel.ts b/scripts/use-channel.ts new file mode 100644 index 00000000..e810c2f2 --- /dev/null +++ b/scripts/use-channel.ts @@ -0,0 +1,150 @@ +/** + * Switch the dev install between the harness channels the plugin supports. + * + * node --experimental-strip-types scripts/use-channel.ts [--floor | --relock] + * + * - `` installs that channel's committed lockfile with + * `--frozen-lockfile`: the channel's `@deepseek-ai` cluster and pi UNPATCHED + * at the newest version the bridge's range admits, which is what an npm + * user of the channel runs. `alpha` is the root lockfile; `rc` is + * `channels/rc/pnpm-lock.yaml`. + * - `--floor` installs the same cluster with every pi held to the range's + * floor (the harness's own pin, which the desktop app ships), unpatched. + * Never committed. + * - `--relock` regenerates the channel's committed lockfile from the root one, + * so only the host packages move (`rc` only: the root lockfile is alpha's, + * and `pnpm install` regenerates it). + * + * Every tracked file the switch rewrites (`package.json`, + * `pnpm-workspace.yaml`, `pnpm-lock.yaml`) is restored when it ends, whatever + * happened, so a switch leaves no diff. What the tree holds is recorded in + * `node_modules/.dsh-channel.json` with a hash of pnpm's own installed-lockfile + * copy, which `test/support/host-versions.ts` reads to know which record to + * check, and ignores once a plain `pnpm install` has replaced that install. + * + * @module scripts/use-channel + */ +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { + CHANNELS, + DEFAULT_CHANNEL, + installedLockHash, + isChannel, + MARKER, + type Marker, +} from "../test/support/host-versions.ts"; +import { + channelManifest, + withoutHostPackages, + workspaceWithPi, +} from "./channel-manifest.ts"; + +const ROOT = join(import.meta.dirname, ".."); +const TRACKED = ["package.json", "pnpm-workspace.yaml", "pnpm-lock.yaml"]; +const USAGE = + "usage: node --experimental-strip-types scripts/use-channel.ts [--floor | --relock]"; + +function fail(message: string): never { + console.error(`error: ${message}\n${USAGE}`); + process.exit(2); +} + +const args = process.argv.slice(2); +const [channel, ...flags] = args; +if (!isChannel(channel)) { + fail(`name a channel: ${Object.keys(CHANNELS).join(" or ")}`); +} +const unknown = flags.filter( + (flag) => flag !== "--floor" && flag !== "--relock" +); +if (unknown.length > 0) { + fail(`unknown option ${unknown.join(" ")}`); +} +const floor = flags.includes("--floor"); +const relock = flags.includes("--relock"); +if (floor && relock) { + fail("--floor is never committed, so it cannot --relock"); +} +if (relock && channel === DEFAULT_CHANNEL) { + fail( + `the root lockfile is ${DEFAULT_CHANNEL}'s; \`pnpm install\` regenerates it` + ); +} + +const record = CHANNELS[channel]; +const path = (file: string): string => join(ROOT, file); +const saved = new Map(TRACKED.map((file) => [file, readFileSync(path(file))])); + +const pnpm = (...argv: string[]): void => { + console.log(`$ pnpm ${argv.join(" ")}`); + const result = spawnSync("pnpm", argv, { cwd: ROOT, stdio: "inherit" }); + if (result.status !== 0) { + throw new Error(`pnpm ${argv.join(" ")} exited with ${result.status}`); + } +}; + +const versionOf = (name: string): string => { + try { + const manifest = JSON.parse( + readFileSync(path(join("node_modules", name, "package.json")), "utf8") + ) as { version?: string }; + return manifest.version ?? "?"; + } catch { + return "not installed"; + } +}; + +rmSync(path(MARKER), { force: true }); +try { + if (channel !== DEFAULT_CHANNEL || floor) { + writeFileSync( + path("package.json"), + channelManifest(String(saved.get("package.json")), record) + ); + } + if (relock) { + writeFileSync( + path("pnpm-lock.yaml"), + withoutHostPackages(String(saved.get("pnpm-lock.yaml"))) + ); + } else if (channel !== DEFAULT_CHANNEL) { + writeFileSync(path("pnpm-lock.yaml"), readFileSync(path(record.lockfile))); + } + if (floor) { + writeFileSync( + path("pnpm-workspace.yaml"), + workspaceWithPi(String(saved.get("pnpm-workspace.yaml")), record.piFloor) + ); + } + pnpm( + "install", + floor || relock ? "--no-frozen-lockfile" : "--frozen-lockfile" + ); + if (relock) { + mkdirSync(dirname(path(record.lockfile)), { recursive: true }); + writeFileSync(path(record.lockfile), readFileSync(path("pnpm-lock.yaml"))); + console.log(`wrote ${record.lockfile}`); + } +} finally { + for (const [file, content] of saved) { + writeFileSync(path(file), content); + } +} + +const installed = installedLockHash(ROOT); +if (installed !== undefined && (channel !== DEFAULT_CHANNEL || floor)) { + const marker: Marker = { channel, floor, installed }; + writeFileSync(path(MARKER), `${JSON.stringify(marker, null, 2)}\n`); +} + +console.log( + [ + `channel ${channel}${floor ? " (pi at the range floor)" : ""}:`, + `dsh-llm-pi-ai ${versionOf("@deepseek-ai/dsh-llm-pi-ai")} (record ${record.dsh}),`, + `pi ${versionOf("@earendil-works/pi-ai")} (record ${floor ? record.piFloor : record.pi}),`, + `cordis ${versionOf("@deepseek-ai/cordis")} (record ${record.cordis})`, + ].join(" ") +); diff --git a/scripts/vendor-version.ts b/scripts/vendor-version.ts index a7f829ca..18207669 100644 --- a/scripts/vendor-version.ts +++ b/scripts/vendor-version.ts @@ -17,6 +17,36 @@ * @module scripts/vendor-version */ +/** + * The extensions a copy can live in. Listed because the scan is recursive and + * would otherwise read every file under its roots. + */ +export const SCANNED_EXTENSIONS = /\.(?:ts|tsx|md|json|ya?ml)$/; + +/** + * Never scanned: the generated tables hold the vendor's data, not copies the + * repository maintains, so a version-shaped string arriving in a refresh is not + * a stale copy - and scanning them would let a vendor move fail the gate that + * only the repository is supposed to be able to break. The same list as + * `GENERATED_OUTPUTS` in `scripts/freshness-scope.ts`, kept here rather than + * imported (an import would make that module an input of this check); + * `test/freshness-scope.test.ts` ties the two. + */ +export const GENERATED_TABLES: ReadonlySet = new Set([ + "src/catalog-data.ts", + "src/go-limits-data.ts", +]); + +/** + * Whether the copy scan reads a file. + * + * @param path - the file's path relative to the repository root, as the scan + * walks it (`src/identity.ts`, `README.md`). + * @returns whether the file can hold a copy the repository maintains. + */ +export const scansFile = (path: string): boolean => + SCANNED_EXTENSIONS.test(path) && !GENERATED_TABLES.has(path); + /** The constant every other copy must agree with, read from `src/identity.ts`. */ export const PINNED_VERSION = /OPENCODE_CLI_VERSION\s*=\s*"(\d+\.\d+\.\d+)"/; diff --git a/scripts/wrapper-forwarder.ts b/scripts/wrapper-forwarder.ts deleted file mode 100644 index 38344c08..00000000 --- a/scripts/wrapper-forwarder.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * The retired alias's forwarder, in one place. - * - * **The wrapper re-exports the canonical package's NAMES, and nothing else.** - * It used to add `export { default } from "dsh-opencode-patch";` — and the - * canonical package has no `default` export, so every load of the wrapper was a - * link error: - * - * ``` - * SyntaxError: The requested module 'dsh-opencode-patch' - * does not provide an export named 'default' - * ``` - * - * That is a hard failure before any of the plugin runs: the alias is a - * published package, and the thing it exists for — letting an existing install - * keep resolving — is exactly what it prevented. - * - * The re-export lives here rather than inline in the publish script because it - * is written to TWO files (the runtime entry and the type declaration), and - * the two must not drift: a `.d.mts` that re-exports a name the package does - * not have is a type error for every consumer, and one that differs from the - * runtime entry is a lie in the declarations. - * - * @module scripts/wrapper-forwarder - */ - -/** The package the wrapper forwards to. */ -export const FORWARDED_PACKAGE = "dsh-opencode-patch"; - -/** - * The forwarder's source, for the runtime entry and the type declaration. - * - * `export *` and nothing else: a star re-export carries whatever names the - * target has, so it cannot name one it does not. - */ -export const wrapperForwarder = (): string => - `export * from "${FORWARDED_PACKAGE}";`; diff --git a/src/adapter-replacement.ts b/src/adapter-replacement.ts deleted file mode 100644 index 43201074..00000000 --- a/src/adapter-replacement.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Take a provider route over from the adapter that currently serves it. - * - * `llm.registerAdapter` refuses a route that already has an adapter - * (`DUPLICATE_ADAPTER`, `dsh-llm/lib/index.js:273`), which is why this plugin used - * to mount EXTRA routes for the protocols its models speak and redirect calls to - * them. That works, but it costs the thing a user notices: the extra routes are - * providers, so they must be hidden from every listing, and the models have to be - * merged back into the route the user actually configured. - * - * The registry itself is the way out, and it needs no unsupported internals: the - * duplicate check reads `adapters`, so REMOVING the current entry first makes the - * route registrable through the ordinary API. Our registration then carries a - * disposer and fires `llm/adapters-updated`, exactly like one the host made. - * - * @module adapter-replacement - */ - -import type { CordisContext } from "./cordis-context.ts"; - -/** A registration as the registry stores it: the adapter, plus its route. */ -interface RouteRegistration { - readonly adapter: Record; -} - -/** The part of the registry this module reads and writes. */ -interface Registry { - readonly adapters?: Map; - readonly registerAdapter?: ( - providers: readonly string[], - adapter: unknown - ) => unknown; -} - -/** - * Replace the adapter behind `routes` with one this plugin builds. - * - * The swap is two steps and the order matters: release the current entry, then - * register. A failure at either step restores what was there, so a composition - * that cannot take the route over is left exactly as it was rather than with the - * provider missing. - * - * @param ctx - the host context, for the registry and the logging. - * @param routes - the provider routes to take over. - * @param build - builds the adapter that will serve them. - * @returns a disposer that gives the routes back, or `undefined` when the - * composition cannot be taken over. - */ -export const replaceRouteAdapters = ( - ctx: CordisContext, - routes: readonly string[], - build: () => Record -): (() => void) | undefined => { - // The registry is read structurally: `adapters` is a field on the service, not - // part of its declared interface. - const { adapters, registerAdapter } = (ctx.llm ?? {}) as unknown as Registry; - if (!(adapters instanceof Map) || typeof registerAdapter !== "function") { - return undefined; - } - // Both halves are required: an adapter with no routes is refused by the host, - // and this module has nothing to say about a route nobody serves. - const present = routes.filter((route) => adapters.has(route)); - if (present.length === 0 || present.length !== routes.length) { - return undefined; - } - - // What was there, kept so the swap is reversible. Held by value: the registry - // entry is replaced, and the host's own effect still believes it holds it. - const previous = new Map(); - for (const route of present) { - const registration = adapters.get(route); - if (registration === undefined) { - return undefined; - } - previous.set(route, registration); - } - - const restore = (): void => { - for (const [route, registration] of previous) { - adapters.set(route, registration); - } - }; - - let adapter: Record; - try { - adapter = build(); - } catch (error) { - ctx.logger?.warn?.( - "[dsh-opencode-patch] could not build the replacement adapter; leaving the route with its own", - error - ); - return undefined; - } - - // Released only now, so a build failure never leaves the provider unserved. - for (const route of present) { - adapters.delete(route); - } - - let dispose: (() => void) | undefined; - try { - const handle: unknown = registerAdapter(present, adapter); - dispose = typeof handle === "function" ? (handle as () => void) : undefined; - } catch (error) { - restore(); - ctx.logger?.warn?.( - "[dsh-opencode-patch] the replacement adapter was refused; the route keeps its own", - error - ); - return undefined; - } - - return () => { - try { - dispose?.(); - } finally { - restore(); - } - }; -}; diff --git a/src/catalog-data.ts b/src/catalog-data.ts index f3436599..d611daf5 100644 --- a/src/catalog-data.ts +++ b/src/catalog-data.ts @@ -1854,6 +1854,21 @@ export const OPENCODE_ZEN_CATALOG: readonly CatalogModelSpec[] = [ }, ], }, + { + context_window: 1_048_576, + cost: { + cache_read: 0, + input: 0, + output: 0, + }, + id: "pulse-alpha-free", + input_modalities: ["text", "image"], + is_free: true, + max_output_tokens: 131_072, + name: "Pulse Alpha Free", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], + }, { context_window: 262_144, cost: { diff --git a/src/config-values.ts b/src/config-values.ts index 073084e5..c0093149 100644 --- a/src/config-values.ts +++ b/src/config-values.ts @@ -76,14 +76,10 @@ export const isKeySourcePolicy = (value: unknown): value is KeySourcePolicy => * The gateway answers on two families and the user, not the catalog, is the one * who knows which their account is entitled to: * - * - `auto` follows the catalog — the `api` the vendor's own entry names, which - * today is the `inference` family. - * - `legacy` forces the older pair, which really is one base per plane - * (`…/zen/v1`, `…/zen/go/v1`). - * - `modern` forces the newer `inference` family, whose base is PER PROTOCOL - * (`…/inference/openai/v1`, `…/inference/anthropic/v1`, - * `…/inference/google/v1beta`, and the `go/` variants) — so "inference/v1" is - * not an address the plugin ever sends, and the copy says `inference/…`. + * - `auto` — an alias of `modern` (the default). + * - `legacy` — the older single base per plane, `/zen/v1` and `/zen/go/v1`. + * - `modern` — the per-protocol `inference` family, + * `/inference/{openai,anthropic,google}/…` and `/inference/go/…` on Go. * * Declared here rather than in `config.ts` so the client card can read the same * list without importing schemastery. diff --git a/src/config.ts b/src/config.ts index 551fd3e1..d470e2ab 100644 --- a/src/config.ts +++ b/src/config.ts @@ -138,7 +138,6 @@ export const CONFIG_DEFAULTS = { maxContextWindow: undefined as number | undefined, modelOverrides: {} as Readonly>, modelRoutes: {} as Readonly>, - internalRoutes: true, originClient: DEFAULT_ORIGIN_CLIENT, providers: DEFAULT_PROVIDERS, readOpencodeStore: false, @@ -169,22 +168,9 @@ export interface PluginConfig { * * This is the OVERRIDE layer over what the shim and its live refresh computed. * A model whose protocol the catalog puts on an internal route can be pinned - * here instead, and a curated deployment that declares its own providers for - * the other protocols can switch {@link internalRoutes} off entirely. + * here instead. */ modelRoutes?: Record; - /** - * Whether to mount the internal per-protocol routes. - * - * On (default) the plugin registers a route for every protocol a plane serves, - * so a declared model that speaks Anthropic or Google is reachable with no - * configuration. Each mounted route is a provider whose model list is the - * WHOLE protocol catalog — the price of not being able to see the deployment's - * own selection from inside a plugin. A deployment that writes its own provider - * entries for those protocols turns this off and declares `modelRoutes` for the - * models that need it. - */ - internalRoutes?: boolean; originClient?: string; providers?: string[]; readOpencodeStore?: boolean; @@ -214,8 +200,8 @@ export interface ResolvedPluginConfig { * Which endpoint family the gateway's requests go to. * * The two families are independently entitled and both are still served, so - * the choice is the user's: `auto` follows the catalog, and `legacy` / - * `modern` pin one. See {@link ENDPOINT_FAMILIES}. + * the choice is the user's: `auto` is an alias of `modern` (the default), and + * `legacy` pins the older single base per plane. See {@link ENDPOINT_FAMILIES}. */ endpoint: EndpointFamily; /** `*` when the core-tool fallback should run for every model. */ @@ -235,8 +221,6 @@ export interface ResolvedPluginConfig { readonly modelOverrides: Readonly>; /** The per-model protocol/base overrides, keyed by model id. */ readonly modelRoutes: Readonly>; - /** Whether the internal per-protocol routes are mounted. */ - readonly internalRoutes: boolean; originClient: string; providers: Set; /** @@ -276,8 +260,8 @@ const resolvedKeySource = (raw: unknown): KeySourcePolicy => { * A row's endpoint family, or the default when it names something else. * * Lenient for the same reason {@link resolvedKeySource} is: a typo in a - * hand-edited row degrades to `auto`, which follows the catalog and is the - * answer that cannot be wrong about which family is current. + * hand-edited row degrades to `auto`, the default, so a typo cannot select a + * family the user never named. */ const resolvedEndpoint = (raw: unknown): EndpointFamily => { const value = readString(raw); @@ -429,10 +413,6 @@ export const resolveConfig = ( maxContextWindow: readPositiveInteger(config.maxContextWindow), modelOverrides: resolveModelOverrides(config.modelOverrides), modelRoutes: resolveModelRoutes(config.modelRoutes), - internalRoutes: readBoolean( - config.internalRoutes, - CONFIG_DEFAULTS.internalRoutes - ), originClient: readString(config.originClient) ?? CONFIG_DEFAULTS.originClient, providers: new Set( @@ -563,10 +543,13 @@ export const Config = z.object({ .string() .default(CONFIG_DEFAULTS.endpoint) .description( - "Which endpoint family the gateway's requests go to. auto: follow the " + - "catalog's own address (today the inference family). legacy: force " + - "https://opencode.ai/zen/v1. modern: force " + - "https://opencode.ai/inference/. The two families are separately " + + "Which endpoint family the gateway's requests go to. modern: the " + + "per-protocol inference family, " + + "https://opencode.ai/inference/{openai,anthropic,google}/... on Zen " + + "and https://opencode.ai/inference/go/{openai,anthropic,google}/... " + + "on Go. auto: an alias of modern, and the default. legacy: the older " + + "single base per plane, https://opencode.ai/zen/v1 and " + + "https://opencode.ai/zen/go/v1. The two families are separately " + "entitled and both still answer, so this is a choice rather than a " + "migration; an unrecognised value falls back to auto." ) @@ -592,14 +575,6 @@ export const Config = z.object({ "Per-model wire routing overrides: { : { api, baseURL } }. The api " + "names a pi protocol; baseURL replaces the catalog's endpoint." ), - internalRoutes: z - .boolean() - .default(CONFIG_DEFAULTS.internalRoutes) - .volatile() - .description( - "Mount the internal per-protocol routes. Off for a deployment that " + - "declares its own providers for the other protocols." - ), usageBaseURL: z .string() .default(CONFIG_DEFAULTS.usageBaseURL) diff --git a/src/fetch-patch.ts b/src/fetch-patch.ts index fc11b3fe..f7c5d7b8 100644 --- a/src/fetch-patch.ts +++ b/src/fetch-patch.ts @@ -210,6 +210,25 @@ export const patchFetch = ( const headers = new Headers(headerSourceOf(input, init)); + // The body, read once, off whichever half carries it. DSH sends inference as + // fetch(new Request(url, { body })) — so init.body is undefined, and two + // things read it: the free-model check on the credential path below and the + // core-tool fallback after it. The free-model check used to read + // `init.body` alone, so for every DSH turn it answered "not free" and a + // keyless free model was reported as a missing credential. + let bodySource: "request" | "init" | "none" = "none"; + let bodyToPatch: RequestInit["body"] = init?.body; + if (bodyToPatch !== undefined) { + bodySource = "init"; + } else if (input instanceof Request) { + try { + bodyToPatch = await input.clone().text(); + bodySource = "request"; + } catch { + bodyToPatch = undefined; + } + } + // Capture the API key from the request headers so that quota monitoring, // usage display, and key resolution don't have to rely purely on static env vars. const extracted = extractApiKeyFromHeaders(headers); @@ -303,11 +322,9 @@ export const patchFetch = ( // an anonymous request anyway, so stripping it here would change nothing // but would also deprive a keyless-but-paid model of a header to fill. // - // `init.body` is read, not the `Request`: the tool-injection block below - // resolves a `Request`-carried body, and this check runs before it. A - // body the switch cannot see answers "not free", which is the safe + // A body the check cannot read answers "not free", which is the safe // side - the warning is the status quo, not a new failure. - !isFreeModelBody(init?.body, isGoPlaneUrl(url) ? "go" : "zen") + !isFreeModelBody(bodyToPatch, isGoPlaneUrl(url) ? "go" : "zen") ) { warnedNoCredential.add(plane); const sources: string[] = [ @@ -324,7 +341,7 @@ export const patchFetch = ( ]; console.warn( `[dsh-opencode-patch] no credential resolved for ${plane}; sending the adapter's placeholder, which the gateway answers with 401. Consulted: ${sources.join("; ")}.`, - "set a key on the route or its env var, or sign in through the settings card" + "set a key on the route or its env var, or turn on readOpencodeStore to use the key an OpenCode installation already holds" ); } } @@ -416,24 +433,8 @@ export const patchFetch = ( const newInit: RequestInit = { ...init, headers }; let requestToSend = input; - // 4. Core tool schema fallback for free-tier /responses models. - // - // DSH sends inference as fetch(new Request(url, { body })) — so init.body - // is undefined and the old `init?.body !== undefined` guard skipped the - // whole block: Agent Team turns left with no bash and the gateway 403s - // the body as non-OpenCode. Read the body off whichever half carries it. - let bodySource: "request" | "init" | "none" = "none"; - let bodyToPatch: RequestInit["body"] = init?.body; - if (bodyToPatch !== undefined) { - bodySource = "init"; - } else if (input instanceof Request) { - try { - bodyToPatch = await input.clone().text(); - bodySource = "request"; - } catch { - bodyToPatch = undefined; - } - } + // 4. Core tool schema fallback for free-tier /responses models, over the + // body read above. let finalBody = bodyToPatch; if (bodyToPatch !== undefined) { const newBody = maybeInjectCoreTools(url, bodyToPatch, headers, { diff --git a/src/go-discovery.ts b/src/go-discovery.ts index b4a0b750..ae2407d1 100644 --- a/src/go-discovery.ts +++ b/src/go-discovery.ts @@ -534,81 +534,6 @@ export const resolveZenCreditInfo = async ( return { isConfigured: zenKey !== undefined }; }; -/** Key resolution details for a routed provider. */ -export interface RoutedKeyDetails { - key?: string; - keyPrefix?: string; - provider: string; - tier: "go" | "zen" | "unknown"; -} - -/** - * Resolve the effective API key and account tier for a currently routed provider. - * - * The routed provider *is* the tier, so a Zen route legitimately resolves a - * Zen key — and so does {@link resolveGoApiKey}, which does not reject one - * either. An earlier guard there refused a key starting `oc_sk_` on the belief - * that the prefix meant "Zen, no Go entitlement"; the same prefix answers `200` - * with real windows, and the guard was removed. What separates the tiers is the - * observed request, never a string. - * - * @param ctx - plugin context - * @param provider - routed provider name (e.g. "opencode-go", "opencode") - * @param policy - which source wins when several resolve. - * @param readOpencodeStore - whether the installation's own store may be read - * as the last resort. Off by default. - */ -export const resolveRoutedKey = async ( - ctx: unknown, - provider: string, - policy: KeySourcePolicy = "auto", - readOpencodeStore = false -): Promise => { - const discovered = discoverGoConfig(ctx, provider); - const isGoRoute = provider === "opencode-go"; - const configuredRef = - discovered.keyEnv ?? - (isGoRoute ? DEFAULT_USAGE_KEY_ENV : "OPENCODE_API_KEY"); - - const key = await firstResolved(KEY_SOURCE_ORDER[policy], { - captured: () => getCapturedApiKey(provider, isGoRoute ? "go" : undefined), - capturedAny: () => getCapturedApiKey(undefined, isGoRoute ? "go" : "zen"), - configured: () => resolveRef(ctx, configuredRef), - literal: () => discovered.literalKey, - opencodeStore: () => - resolveOpencodeStoreKey( - isGoRoute ? "opencode-go" : "opencode", - readOpencodeStore - ), - }); - - // 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. - let tier: "go" | "zen" | "unknown" = "unknown"; - if (isGoRoute || (key !== undefined && key.startsWith("sk-"))) { - tier = "go"; - } else if ( - provider === "opencode" || - (key !== undefined && key.startsWith("oc_sk_")) - ) { - tier = "zen"; - } - - // The floor matches the slice, so an 8- or 9-character key is not reported - // whole: a "prefix" that happens to be the entire secret leaks more than the - // eight characters it was supposed to. - const keyPrefix = - key !== undefined && key.length >= 10 ? key.slice(0, 10) : undefined; - - return { - key, - keyPrefix, - provider, - tier, - }; -}; - /** One model entry as the operator declared it in a provider row. */ export interface DeclaredModel { readonly id: string; diff --git a/src/go-limits.ts b/src/go-limits.ts index d7d947bf..e00d8c55 100644 --- a/src/go-limits.ts +++ b/src/go-limits.ts @@ -20,9 +20,11 @@ * * So it is GENERATED from the vendor's own machine-readable doc * (`https://opencode.ai/docs/go.md`, which is the page's markdown source, not a - * scrape of rendered HTML) by `scripts/generate-go-limits.ts`, and CI fails when - * it drifts — the same arrangement as `catalog-data.ts` / models.dev, for the - * same reason. + * scrape of rendered HTML) by `scripts/generate-go-limits.ts`, and CI checks it + * for drift — the same arrangement as `catalog-data.ts` / models.dev, for the + * same reason: a pull request that changes the generator (or a module it + * loads) fails on a stale table; any other pull request, and a push to main, + * warns, and the nightly run proposes the refresh. * * ## What still cannot be shown, and why that is not an oversight * diff --git a/src/google-bridge.ts b/src/google-bridge.ts index d1d7c07a..548c7aaa 100644 --- a/src/google-bridge.ts +++ b/src/google-bridge.ts @@ -30,13 +30,14 @@ * and deliberately not declared as a dependency. * * **Why not a dependency.** The harness wrapper (`dsh-llm-pi-ai`) already - * depends on `@earendil-works/pi-ai` with a RANGE (`^1.1.0`), so the host - * installs exactly the pi its own release was tested against — 1.4.0 for the - * `0.2.1-rc.4` harness line, whatever the next harness line moves it to. - * Declaring the same package as an exact dependency here puts a SECOND pi in - * the tree and turns every harness bump into a version-skew bug: the worst - * measured shape is a degraded Google route whose error names the model rather - * than the version. + * depends on `@earendil-works/pi-ai` with a RANGE (`^1.1.0` on the rc + * channel, `^1.3.0` on alpha). An npm install of either channel resolves it to + * the newest 1.x the range admits; the desktop app ships the pi the harness's + * own workspace pins; this repo's dev install follows the channel's committed + * lockfile (`test/support/host-versions.ts`). Declaring the same package as an + * exact dependency here puts a SECOND pi in the tree and turns every harness + * bump into a version-skew bug: the worst measured shape is a degraded Google + * route whose error names the model rather than the version. * * So the rule is the user's own: *use whatever the harness provides*. The * lookup below asks the harness which pi that is, from the wrapper's own @@ -411,8 +412,9 @@ const withArgonThinkingLevel = ( * one — a provider is a record pi dispatches through, not a class it * instantiates. `getModels` returns the models of the Zen plane whose SDK is * `@ai-sdk/google`, each carrying the base URL this protocol uses on that - * plane (`…/zen/v1`), so the request lands on - * `…/zen/v1/models/:streamGenerateContent`. + * plane (`…/inference/google/v1beta` under auto or modern, and + * `…/inference/go/google/v1beta` on Go), so the request lands on + * `…/inference/google/v1beta/models/:streamGenerateContent`. * * The streams are pi's raw implementation. The session, client, project and * User-Agent headers are NOT added here: the fetch patch already restores all @@ -420,8 +422,8 @@ const withArgonThinkingLevel = ( * append, so the bridge would only duplicate that work. * * @param specs - the catalogued models the route serves. - * @param family - the endpoint family to send them to; the catalog's own - * address by default. + * @param family - the endpoint family to send them to; auto (an alias of + * modern) by default. * @returns the provider the adapter's collection will hold. */ export const googleProviderFor = ( @@ -566,8 +568,8 @@ export interface GoogleBridgeInputs { */ readonly readOpencodeStore?: boolean; /** - * Which endpoint family the bridge's models are sent to. The catalog's own - * address by default, and the same choice the internal routes use so a + * Which endpoint family the bridge's models are sent to. Auto (an alias of + * modern) by default, and the same choice the internal routes use so a * deployment cannot end up on two families at once. */ readonly endpoint?: EndpointFamily; diff --git a/src/identity.ts b/src/identity.ts index 6823a580..003f76de 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -2,7 +2,11 @@ * Component and package identity. * * The same plugin answers to several names, and they are not interchangeable: - * see `AGENTS.md` ("Package vs component") for the full mapping. Keeping the + * `name` is the component id (and the unscoped npm name), `LEGACY_NAME` and + * `LEGACY_PKG` are the pre-rename ids an old row may still use, and + * `SCOPED_PKG` in `settings-page.tsx` is the scoped npm name the settings card + * registers under. README "Changed in 1.x" records the retired + * `@viztor/dsh-opencode` wrapper. Keeping the * literals here — rather than scattered across `index.ts`, `lifecycle.ts` and * the client bundle — makes the one place that must agree with * `package.json`/`cordis.patch.yml` obvious. diff --git a/src/index.ts b/src/index.ts index 4ae34874..a6a663f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,216 +1,25 @@ /** * `dsh-opencode-patch` — host plugin entry (public barrel). * - * This module is intentionally thin: it re-exports identity, the `apply()` - * lifecycle, and the public surface of every module. Each concern lives in - * the module that owns it: + * This is the package's contract with the host and nothing more: `apply`, + * `Config`, `inject` and `name` — the four names a cordis host reads off a + * plugin — and the config types a user of `Config` needs to name. Every other + * module is internal; `test/public-api.test.ts` pins this list, so widening it + * is a reviewed change rather than a side effect. * - * | module | concern | - * | ------------------ | ------------------------------------------------ | - * | `identity.ts` | component/package name constants + `inject` | - * | `lifecycle.ts` | `apply()` and its per-side-effect installers | - * | `guards.ts` | structural `unknown` predicates | - * | `config-values.ts` | host/client-shared config coercion readers | - * | `config.ts` | defaults, types, resolver, settings schema | - * | `session.ts` | deterministic `ses_…` session identity | - * | `turn-store.ts` | ALS turn state carried across a streamed turn | - * | `tool-fallback.ts` | free-tier `/responses` read+bash schema fallback | - * | `fetch-patch.ts` | gateway request recognition + header fixes | - * | `stream-hook.ts` | `llm/stream` turn capture | - * | `cordis-context.ts`| structural view of the Cordis host context | - * | `go-discovery.ts` | Go credential/gateway discovery + resolution | - * | `usage.ts` | Go quota service + typert remote registration | - * | `usage-contract.ts`| remote shapes, validation, typert descriptor | - * | `session-cost.ts` | per-turn token/dollar accounting | - * | `catalog-data.ts` | the static Go/Zen model catalog (pure data) | - * | `models-catalog.ts`| catalog parsing, SWR refresh, `/models` enrich | - * | `models-discovery.ts` | provider-aware model-discovery decoration | - * | `debug.ts` | JSONL stream-debug append log | + * Tests reach the internals through `test/support/src.ts`, which re-exports + * each name from the module that defines it, never through this file. * * @module dsh-opencode-patch */ -// ── identity & lifecycle ──────────────────────────────────────────────────── -export { - inject, - LEGACY_NAME, - LEGACY_PKG, - name, - OPENCODE_CLI_VERSION, - OPENCODE_PATCH_USER_AGENT, - OPENCODE_USER_AGENT, -} from "./identity.ts"; +export { inject, name } from "./identity.ts"; export { apply } from "./lifecycle.ts"; - -// ── configuration ─────────────────────────────────────────────────────────── export { - CONFIG_DEFAULTS, Config, - DEFAULT_ENDPOINT_FAMILY, - DEFAULT_FREE_MODEL_MARKER, - DEFAULT_GATEWAY_URLS, - DEFAULT_INJECT_PROJECT, - DEFAULT_KEY_SOURCE, - DEFAULT_ORIGIN_CLIENT, - DEFAULT_PROVIDERS, - DEFAULT_SESSION_ID_ENV, - DEFAULT_USAGE_BASE_URL, - DEFAULT_USAGE_KEY_ENV, - ENDPOINT_FAMILIES, - KEY_SOURCE_POLICIES, - resolveConfig, - type EndpointFamily, - type KeySourcePolicy, type ModelOverrideConfig, + type ModelRouteConfig, type PluginConfig, type ResolvedPluginConfig, } from "./config.ts"; -export { - ALL_MODELS_MARKER, - readBoolean, - readString, - readStringList, - unwrapNode, -} from "./config-values.ts"; - -// ── session identity & turn state ─────────────────────────────────────────── -export { - fallbackSessionId, - isOpenCodeMessageId, - isOpenCodeSessionId, - headerValueFor, - newOpenCodeMessageId, - openCodeSessionIdFor, - OPENCODE_UA, - pinnedSessionId, - sessionIdForRequest, - PARENT_SESSION_ALT_HEADER, - PARENT_SESSION_HEADER, - SESSION_AFFINITY_HEADER, - SESSION_HEADER, -} from "./session.ts"; -export { type ActiveTurnState, withStore } from "./turn-store.ts"; - -// ── request patching ──────────────────────────────────────────────────────── -export { - DUMMY_BASH_TOOL, - DUMMY_READ_TOOL, - isCoreToolModel, - maybeInjectCoreTools, - RESPONSES_PATH, -} from "./tool-fallback.ts"; -export { - hasSessionHeader, - isOpenCodeRequest, - patchFetch, -} from "./fetch-patch.ts"; -export { type CordisContext } from "./cordis-context.ts"; - -// ── Go discovery & quota ──────────────────────────────────────────────────── -export { - clearCapturedApiKeys, - extractApiKeyFromHeaders, - getCapturedApiKey, - isPlaceholderApiKey, - recordCapturedApiKey, - tierForRequest, -} from "./key-capture.ts"; -export { - discoverGoConfig, - effectiveGoKeyRef, - resolveGoApiKey, - resolveGoBaseURL, - resolveOpencodeStoreKey, - resolveRoutedKey, - resolveZenCreditInfo, - toGoBaseURL, - type RoutedKeyDetails, -} from "./go-discovery.ts"; -export { GoUsageService } from "./usage.ts"; -export { - parseAccountRef, - parseGoUsage, - parseUsageQuery, - type GoUsage, - type UsageQuery, - type UsageWindow, -} from "./usage-contract.ts"; -export { - calculateTurnCost, - clearSessionUsageStore, - describeModel, - formatModelRate, - formatUsd, - getSessionUsage, - recordTurnUsage, - type ModelCostRate, - type SessionUsageSnapshot, -} from "./session-cost.ts"; - -// ── model catalog & discovery ─────────────────────────────────────────────── -export { - CATALOG_REVALIDATION_TTL_MS, - DSH_SUPPORTED_MODALITIES, - MODELS_DEV_TIMEOUT_MS, - MODELS_DEV_URL, - MODELS_OPENCODE_URL, - OPENCODE_GO_CATALOG, - OPENCODE_ZEN_CATALOG, - enrichModelsResponse, - catalogPlaneForRoute, - findModelSpec, - findModelSpecOn, - getLiveCatalog, - getLiveGoCatalog, - getLiveZenCatalog, - isDeprecatedModelOn, - isGoModelsListingUrl, - isModelsListingUrl, - parseModelsDevCatalog, - refreshCatalog, - sanitizeModalities, - type CatalogModelSpec, - type CatalogProvider, - type ParsedCatalogs, -} from "./models-catalog.ts"; -export { - decorateModelDiscovery, - decorateModelInfo, - decoratePlaneAdapters, - hideResponsesRoute, - isRouteRegistered, - mergeDiscoveredModels, - resolveDiscoveryProvider, - resolveEffectiveContextWindow, - type DiscoveryCandidate, -} from "./models-discovery.ts"; -export { - ANTHROPIC_ROUTE, - ANTHROPIC_SDK, - GOOGLE_INTERNAL_ROUTE, - GOOGLE_SDK, - MISTRAL_SDK, - SELF_MOUNTED_ROUTES, - INTERNAL_ROUTES, - PLANES, - PROTOCOL_FOR_SDK, - RESPONSES_ROUTE, - RESPONSES_SDK, - GO_RESPONSES_ROUTE, - GO_ANTHROPIC_ROUTE, - ROUTE_FOR_PLANE_PROTOCOL, - ROUTE_FOR_PROTOCOL, - UNSERVED_SDKS, - internalRouteFor, - isInternalRoute, - isServableSdk, -} from "./responses-routes.ts"; -export { - inheritedCredentialRef, - loadPiAi, - modelsForSdk, - narrowToDeclared, - undescribedDeclaredModels, - unroutedDeclaredModels, - registerResponsesProvider, -} from "./responses-provider.ts"; +export { type EndpointFamily, type KeySourcePolicy } from "./config-values.ts"; diff --git a/src/key-capture.ts b/src/key-capture.ts index f1aee1ac..cd03fbe3 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 @@ -219,10 +212,10 @@ export const recordCapturedApiKey = ( if (key.length === 0 || isPlaceholderApiKey(key)) { return; } - // 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); + // Deliberately NOT the meter's route-first precedence (`resolveGoApiKey`): 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); if (typeof provider === "string" && provider.length > 0) { capturedKeysByProvider.set(provider, { key, tier }); diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 85b3cb08..a024245d 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -95,12 +95,6 @@ const logRenameNotice = (ctx: CordisContext): void => { } }; -/** - * Register the Go quota service and its Typert remote face. - * - * Runs before the fetch patch so a composition without `globalThis.fetch` - * still gets a working meter. - */ /** * Publish a service through `ctx.provide`, when `ctx.plugin` is not available. * @@ -149,6 +143,12 @@ const mountByProvide = ( */ const usageMountedOn = new WeakSet(); +/** + * Register the Go quota service and its Typert remote face. + * + * Runs before the fetch patch so a composition without `globalThis.fetch` + * still gets a working meter. + */ const installUsageService = ( ctx: CordisContext, config: ResolvedPluginConfig @@ -274,14 +274,6 @@ const verifyUsageService = (ctx: CordisContext): void => { ); }; -/** - * Wrap `globalThis.fetch` so claimed OpenCode traffic carries session affinity - * and the gateway headers, restoring both on dispose. - * - * @returns `false` when there is no fetch to wrap. The caller then stops: the - * stream hook and discovery must not attach, or turns would run without the - * session affinity this plugin exists to provide. - */ /** * Roots that already carry the purge service, for the same reason the usage * service keeps its own set: `apply` runs once per live reload, and a service @@ -336,6 +328,14 @@ const installStoreService = (ctx: CordisContext): void => { } }; +/** + * Wrap `globalThis.fetch` so claimed OpenCode traffic carries session affinity + * and the gateway headers, restoring both on dispose. + * + * @returns `false` when there is no fetch to wrap. The caller then stops: the + * stream hook and discovery must not attach, or turns would run without the + * session affinity this plugin exists to provide. + */ const installFetchPatch = ( ctx: CordisContext, config: ResolvedPluginConfig, @@ -411,31 +411,7 @@ const installFetchPatch = ( // Own the Responses route from here, so the user configures nothing: they // keep the `opencode` provider and key they already have. Best-effort — a // route declared in the profile still works if this cannot register. - // The registration is async (it reads `llm-pi-ai` off the loader), so the - // disposer cannot live in a local the effect body fills in later: an effect - // that re-runs before the mount settles would find `undefined` here, release - // nothing, and leave the previous mount's routes registered — and the mount - // that then arrives would be an orphan owned by a disposed effect. So the - // effect owns the WITHDRAWAL, not the mount: a mount that completes after - // disposal is released on the spot, and one that completes before it is - // released by the disposer below. - let stopResponsesProvider: (() => void) | undefined; - let released = false; - const releaseResponsesProvider = (): void => { - released = true; - stopResponsesProvider?.(); - stopResponsesProvider = undefined; - }; - void (async () => { - // The resolved config rides in: the mount reads the endpoint family and - // the installation-store switch from it. - const stop = await registerResponsesProvider(ctx, config); - if (released) { - stop?.(); - return; - } - stopResponsesProvider = stop; - })(); + const releaseResponsesProvider = mountInternalRoutes(ctx, config); return () => { releaseResponsesProvider(); stopCatalogHiding?.(); @@ -460,6 +436,27 @@ const installStreamHook = ( ctx.on?.("llm/stream", createStreamHook(ctx, config, als), { prepend: true }); }; +/** + * The settings the LATEST activation on a root resolved, which is what every + * discovery callback answers from. + * + * Keyed on the root for the same reason the services are: `apply` runs once per + * live reload, and the host refuses a second registration under a namespace that + * is still held. The registrations therefore belong to the process, and only the + * settings they read move with each activation. + */ +const discoveryConfigOn = new WeakMap(); + +/** The namespaces this plugin publishes, and the catalog each one answers with. */ +const DISCOVERY_NAMESPACES: readonly { + readonly ns: string; + readonly catalog: () => readonly CatalogModelSpec[]; +}[] = [ + { catalog: () => [...getLiveGoCatalog(), ...getLiveZenCatalog()], ns: name }, + { catalog: getLiveGoCatalog, ns: "opencode-go" }, + { catalog: getLiveZenCatalog, ns: "opencode" }, +]; + /** * Publish the canonical Go/Zen catalog to DSH's model discovery. * @@ -467,73 +464,112 @@ const installStreamHook = ( * gateway listing enrichment, so both sit behind the one `enrichModels` * toggle: turning it off leaves DSH's own catalog and the raw gateway * listings untouched. + * + * Registered once per root and never withdrawn, like the usage service. The + * earlier shape registered on every activation and swallowed the host's + * duplicate refusal, so the FIRST activation's settings answered forever: a + * reload that changed `modelOverrides`, `maxContextWindow` or `enrichModels` + * changed nothing the picker saw. Each namespace is registered on its own, so + * one refusal no longer skips the others. */ const installModelDiscovery = ( ctx: CordisContext, config: ResolvedPluginConfig ): void => { - if (!config.enrichModels) { + const root: CordisContext = ctx.root ?? ctx; + const registered = discoveryConfigOn.has(root); + if (!registered && !config.enrichModels) { return; } - try { - const { llm } = ctx; - if (typeof llm?.registerModelDiscovery !== "function") { - return; + discoveryConfigOn.set(root, config); + if (registered) { + return; + } + const { llm } = ctx; + if (typeof llm?.registerModelDiscovery !== "function") { + return; + } + const current = (): ResolvedPluginConfig => + discoveryConfigOn.get(root) ?? config; + for (const { ns, catalog } of DISCOVERY_NAMESPACES) { + try { + llm.registerModelDiscovery(ns, () => { + const settings = current(); + return Promise.resolve( + settings.enrichModels + ? toDiscovered( + catalog(), + settings.maxContextWindow, + settings.modelOverrides + ) + : [] + ); + }); + } catch (error) { + // Non-fatal, and said: a picker missing a plane is otherwise a silence. + ctx.logger?.warn?.( + `[dsh-opencode-patch] model discovery for ${ns} did not register`, + error + ); } - llm.registerModelDiscovery(name, () => - Promise.resolve( - toDiscovered( - [...getLiveGoCatalog(), ...getLiveZenCatalog()], - config.maxContextWindow, - config.modelOverrides - ) - ) - ); - llm.registerModelDiscovery("opencode-go", () => - Promise.resolve( - toDiscovered( - getLiveGoCatalog(), - config.maxContextWindow, - config.modelOverrides - ) - ) - ); - llm.registerModelDiscovery("opencode", () => - Promise.resolve( - toDiscovered( - getLiveZenCatalog(), - config.maxContextWindow, - config.modelOverrides - ) - ) - ); - } catch { - // Model discovery registration is non-fatal } }; /** - * Plugin entry: register the quota service, patch `fetch` for OpenCode - * traffic, and capture turn state for configured providers. + * Mount the internal protocol routes for one activation, and own their withdrawal. * - * Order matters: - * 1. the rename notice, so it is the first thing a legacy row logs; - * 2. the quota service, before the fetch patch, so a composition without - * `globalThis.fetch` still gets a working meter; - * 3. the fetch patch, which bails out (warning) when there is nothing to - * wrap — `on` must not be attached in that case, or turns would run - * without session affinity; - * 4. the stream hook and model discovery, which only make sense once the - * request path is patched. + * The registration is async (it reads `llm-pi-ai` off the loader), so the + * disposer cannot live in a local the effect body fills in later: an effect + * that re-runs before the mount settles would find `undefined`, release nothing, + * and leave the previous mount's routes registered. So this owns the + * WITHDRAWAL, not the mount: a mount that completes after release is withdrawn + * on the spot, and one that completes before it is withdrawn by the release. * - * @param ctx - the Cordis host context. - * @param rawConfig - the row config, if any. + * A REJECTED mount is reported and contained. It used to escape as an unhandled + * rejection from a `void` IIFE — which Node's default mode turns into a process + * exit — whenever the Google bridge failed to register. + * + * @param ctx - the host context. + * @param config - the resolved settings the mount reads. + * @param register - the mount itself; a parameter so its failure is testable. + * @returns the release, safe to call at any point and more than once. */ +export const mountInternalRoutes = ( + ctx: CordisContext, + config: ResolvedPluginConfig, + register: typeof registerResponsesProvider = registerResponsesProvider +): (() => void) => { + let stop: (() => void) | undefined; + let released = false; + void (async () => { + let next: (() => void) | undefined; + try { + next = await register(ctx, config); + } catch (error) { + ctx.logger?.warn?.( + "[dsh-opencode-patch] the internal routes did not mount; routes declared in the profile are unaffected", + error + ); + return; + } + if (released) { + next?.(); + return; + } + stop = next; + })(); + return () => { + released = true; + stop?.(); + stop = undefined; + }; +}; + /** * Whether this plane holds a credential the gateway would accept. * - * Deliberately synchronous and deliberately generous: a declared env key, a key - * captured from a live request, or an org written by a completed login. The + * Deliberately synchronous and deliberately generous: a declared env key or a + * key captured from a live request. The * SQLite store is not consulted because it is opt-in and off by default, and * calling a deployment "keyless" when it does hold a credential would hide models * it can actually use. @@ -556,6 +592,23 @@ const hasPlaneCredential = (plane: string): boolean => { return getCapturedApiKey(plane) !== undefined; }; +/** + * Plugin entry: register the quota service, patch `fetch` for OpenCode + * traffic, and capture turn state for configured providers. + * + * Order matters: + * 1. the rename notice, so it is the first thing a legacy row logs; + * 2. the quota service, before the fetch patch, so a composition without + * `globalThis.fetch` still gets a working meter; + * 3. the fetch patch, which bails out (warning) when there is nothing to + * wrap — `on` must not be attached in that case, or turns would run + * without session affinity; + * 4. the stream hook and model discovery, which only make sense once the + * request path is patched. + * + * @param ctx - the Cordis host context. + * @param rawConfig - the row config, if any. + */ export const apply = ( ctx: CordisContext, rawConfig: PluginConfig = {} diff --git a/src/models-catalog.ts b/src/models-catalog.ts index 65418e82..00c3cc22 100644 --- a/src/models-catalog.ts +++ b/src/models-catalog.ts @@ -145,6 +145,25 @@ let refreshPromise: }> | undefined; +/** + * Put the catalog back to the bundled shim, as a fresh process holds it. + * + * The live maps, the retired-id sets and the refresh bookkeeping are module + * state for the life of the process, which is right for the host and wrong for + * a test: a case that refreshes from a fixture would otherwise decide what every + * later case in the file sees. The sibling resets are `clearCapturedApiKeys` and + * `clearSessionUsageStore`. + */ +export const resetCatalogState = (): void => { + activeGoCatalog = new Map(OPENCODE_GO_CATALOG.map((m) => [m.id, m])); + activeZenCatalog = new Map(OPENCODE_ZEN_CATALOG.map((m) => [m.id, m])); + deprecatedGoIds.clear(); + deprecatedZenIds.clear(); + lastRefreshedAt = 0; + lastRefreshSucceeded = false; + refreshPromise = undefined; +}; + export interface ParsedCatalogs { /** * The endpoint each plane's provider declares, read from the catalog itself @@ -722,7 +741,11 @@ export const enrichModelsResponse = async ( // If the upstream returned non-JSON, generate the full catalog response. } - const isGo = isGoModelsListingUrl(url); + // The plane from the PATH alone. The caller has already decided this is a + // listing, against the deployment's own gateway markers; asking + // `isGoModelsListingUrl` again applied the DEFAULT markers, so a relay's + // `/zen/go/v1/models` was answered with the Zen catalog. + const isGo = isGoPlaneUrl(url); const catalog = isGo ? getLiveGoCatalog(fetchFn) : getLiveZenCatalog(fetchFn); const catalogById = new Map(catalog.map((m) => [m.id, m])); const merged = new Map>(); diff --git a/src/models-discovery.ts b/src/models-discovery.ts index fa0ef70a..d5e4a731 100644 --- a/src/models-discovery.ts +++ b/src/models-discovery.ts @@ -451,7 +451,12 @@ export const hideResponsesRoute = ( return []; } const models = await Reflect.apply(originalListModels, this, [provider]); - if (!Array.isArray(models)) { + // **Only the routes this plugin claims.** A provider the deployment did + // not hand it — a direct `anthropic` or `openai` route — is answered by its + // own adapter and nothing else: the servability rule below reads OUR + // catalog, and against a foreign route it dropped real rows (a new Claude + // or GPT id has no OpenCode route, so it read as "unservable"). + if (!Array.isArray(models) || !config.providers.has(provider)) { return models; } // **An empty answer from a route that DECLARES models is a credential @@ -1072,6 +1077,13 @@ export const decorateModelInfo = ( model: string, signal?: AbortSignal ): Promise => { + // A provider this plugin does not claim is answered by its own adapter, + // unchanged and un-caught. Our catalog is OpenCode's: substituting it for a + // foreign route's failure, or overwriting a foreign route's limits with it, + // describes a model the user is not calling. + if (!config.providers.has(provider) && !isInternalRoute(provider)) { + return Reflect.apply(original, llm, [provider, model, signal]); + } let resolved: unknown; try { resolved = await Reflect.apply(original, llm, [provider, model, signal]); diff --git a/src/opencode-store-contract.ts b/src/opencode-store-contract.ts index 3a2b5de5..556bdcb3 100644 --- a/src/opencode-store-contract.ts +++ b/src/opencode-store-contract.ts @@ -6,6 +6,10 @@ * @module dsh-opencode-patch/opencode-store-contract */ +import type { TypertRemoteContribution } from "@deepseek-ai/dsh-typert-protocol"; + +import { isRecord } from "./utils/guards.ts"; + export const STORE_NAMESPACE = "opencodeStore"; /** Sanitized summary of an account found in the local OpenCode installation. */ @@ -34,3 +38,105 @@ export interface OpencodeStoreInspection { readonly message?: string; readonly path?: string; } + +const ACCOUNT_TYPES: ReadonlySet = new Set(["oauth", "key", "unknown"]); + +/** The string fields of an account summary the client may read. */ +const ACCOUNT_STRINGS = [ + "accountId", + "email", + "expiresAt", + "label", + "orgName", +] as const; + +/** + * Parse an account summary through a whitelist, or `undefined` when its shape + * is not one. Unknown fields are dropped rather than passed on: whatever the host + * adds later reaches the client only by being named here. + */ +const parseAccount = (value: unknown): OpencodeStoreAccount | undefined => { + if (!isRecord(value)) { + return undefined; + } + const raw = value; + if (typeof raw.type !== "string" || !ACCOUNT_TYPES.has(raw.type)) { + return undefined; + } + const account: Record = { type: raw.type }; + for (const field of ACCOUNT_STRINGS) { + if (typeof raw[field] === "string") { + account[field] = raw[field]; + } + } + for (const field of ["expired", "refreshable"] as const) { + if (typeof raw[field] === "boolean") { + account[field] = raw[field]; + } + } + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- built field by field from the whitelist above. + return account as unknown as OpencodeStoreAccount; +}; + +/** + * Parse an inspection answer, or `undefined` when it is not one. + * + * The same rule as the login contract's: a whitelist, so a field the host did + * not mean to send — a token, say — cannot reach the card by accident. + * + * @param value - what the remote answered. + * @returns the inspection, or `undefined`. + */ +export const parseStoreInspection = ( + value: unknown +): OpencodeStoreInspection | undefined => { + if (!isRecord(value)) { + return undefined; + } + const raw = value; + if (typeof raw.found !== "boolean") { + return undefined; + } + const account = + raw.account === undefined ? undefined : parseAccount(raw.account); + if (raw.account !== undefined && account === undefined) { + return undefined; + } + return { + found: raw.found, + ...(account === undefined ? {} : { account }), + ...(typeof raw.message === "string" ? { message: raw.message } : {}), + ...(typeof raw.path === "string" ? { path: raw.path } : {}), + }; +}; + +/** The wire codec for one inspection, in the shape `usageCodec` uses. */ +const storeInspectionCodec = { + create: () => ({ parse: parseStoreInspection }), + mode: "strict" as const, + schema: { parse: parseStoreInspection }, + typeSymbol: "dsh-opencode-patch#OpencodeStoreInspection", +}; + +/** + * The store-inspection namespace, as a contribution the client mounts. + * + * **Without it the card's inspection row could never answer.** A Remote is only + * reachable from the browser when a contribution carrying its descriptor was + * mounted on the client — the same finding `purgeRemote` records — and this one + * was not: the host mounted the service, and the row reported "unavailable". + */ +export const storeRemote: TypertRemoteContribution = { + descriptors: [ + { + id: "dsh-opencode-patch#opencodeStore/inspect", + invocation: { kind: "direct" }, + method: "inspect", + namespace: STORE_NAMESPACE, + parameters: [], + result: storeInspectionCodec, + service: STORE_NAMESPACE, + }, + ], + package: "dsh-opencode-patch", +}; diff --git a/src/opencode-store.ts b/src/opencode-store.ts index 3cb5347a..0028fc0f 100644 --- a/src/opencode-store.ts +++ b/src/opencode-store.ts @@ -136,10 +136,16 @@ export const readOpencodeCredentials = ( // sets it, and otherwise the legacy rows are read newest-first rather than // being dropped - which is the difference between this reader working and // answering nothing on a machine that does have credentials. - `SELECT integration_id AS integrationId, label, value FROM credential - WHERE active = 1 - OR (active IS NULL AND NOT EXISTS (SELECT 1 FROM credential WHERE active = 1)) - ORDER BY time_updated DESC` + // + // The fallback is PER INTEGRATION. Uncorrelated, one flagged `opencode-go` + // row hid every legacy `opencode` row, so the Zen key vanished from the + // reader while the inspector, which reads `opencode` alone, still said "ok". + `SELECT c.integration_id AS integrationId, c.label, c.value FROM credential c + WHERE c.active = 1 + OR (c.active IS NULL AND NOT EXISTS ( + SELECT 1 FROM credential f + WHERE f.active = 1 AND f.integration_id = c.integration_id)) + ORDER BY c.time_updated DESC` ); if (found === undefined) { return undefined; diff --git a/src/pi-provider.ts b/src/pi-provider.ts index 8baae6ad..558350de 100644 --- a/src/pi-provider.ts +++ b/src/pi-provider.ts @@ -80,10 +80,10 @@ export const LEGACY_PLANE_API: Readonly> = { /** * The base URL one plane's requests go to under one endpoint family. * - * `auto` is the catalog's own value, so following the vendor costs nothing here; - * `legacy` and `modern` pin the family explicitly. The two families are - * independently entitled, which is why a user can choose rather than being - * carried along by whatever the catalog happens to name. + * `auto` is an alias of `modern`: both answer the per-protocol inference + * family's plane base, and `legacy` pins the older single base per plane. The + * two families are independently entitled, which is why a user can choose + * rather than being carried along by whatever the catalog happens to name. * * @param plane - the gateway plane. * @param family - the endpoint family to resolve. @@ -198,8 +198,8 @@ const INFERENCE_BASE: Readonly< * * Memoised per family: the table is rebuilt at most once per family per process, * and the map keys are exactly the accepted {@link EndpointFamily} values, so an - * unknown family falls through to the catalog's own answer rather than to - * `undefined`. + * unknown family falls through to auto's answer (an alias of modern) rather + * than to `undefined`. */ const BASES_BY_FAMILY = new Map(); @@ -245,7 +245,7 @@ function withPlaneBase( type BaseTable = Readonly>>>; /** - * The base URL each protocol uses on each plane, following the catalog. + * The base URL each protocol uses on each plane under auto, an alias of modern. * * Kept as the default table for callers that name no family; read * {@link baseForProtocol} with an explicit family once one is known. @@ -293,7 +293,7 @@ const isPlane = (value: string): value is Plane => * * @param plane - the gateway plane. * @param protocol - the protocol the request speaks. - * @param family - the endpoint family; the catalog's own endpoint by default. + * @param family - the endpoint family; auto (an alias of modern) by default. * @returns the base URL, or undefined when the table does not carry that plane. */ export const baseForProtocol = ( @@ -406,7 +406,7 @@ const ALL_THINKING_LEVELS = [ * @param spec - the catalogued model. * @param protocol - the protocol it speaks, from {@link protocolFor}. * @param base - pi's installed catalog entry for this id, when it has one. - * @param family - the endpoint family to send it to; the catalog's own endpoint + * @param family - the endpoint family to send it to; auto (an alias of modern) * by default. * @returns the model object pi's provider dispatches on. */ diff --git a/src/responses-provider.ts b/src/responses-provider.ts index 643ed0b5..a4aa707f 100644 --- a/src/responses-provider.ts +++ b/src/responses-provider.ts @@ -764,10 +764,12 @@ const mountScope = ( /** * Declared models that speak a protocol nothing in this composition serves. * - * The case this exists for: `internalRoutes: false` turns the automatic mount off, - * and a declared model whose protocol differs from its provider's then has no - * route to answer for it. Silence would be the worst outcome — the model is in the - * picker and every call fails — so the caller logs these by name and protocol. + * The case this exists for: the automatic mount stood down (no `llm-pi-ai` in + * this composition, or the early exit with nothing left to mount, where a + * route that did not mount is still missing), so a declared model whose + * protocol differs from its provider's has no route to answer for it. Silence would + * be the worst outcome — the model is in the picker and every call fails — so + * the caller logs these by name and protocol. * * A model the catalog does not describe is skipped: it cannot be known to need * anything else, and guessing would produce a warning that is wrong more often @@ -1047,7 +1049,7 @@ export const registerResponsesProvider = async ( console.warn( `[dsh-opencode-patch] ${gaps.length} declared model(s) speak a protocol no ` + `route serves: ${gaps.join(", ")} — declare provider routes for them ` + - "(see modelRoutes), or set internalRoutes: true" + "(see modelRoutes)" ); }; // A route the deployment declared is served by that declaration. @@ -1059,17 +1061,6 @@ export const registerResponsesProvider = async ( } } - // A curated deployment can turn the mount off: every mounted route is a - // provider whose model list is the protocol's catalog, which is how models - // nobody selected reach a picker. Such a deployment declares its own provider - // entries (and `modelRoutes` for the ids that need them) instead. - if (config?.internalRoutes === false) { - ctx.logger?.info?.( - "[dsh-opencode-patch] internal routes are off (internalRoutes: false); a declared provider must serve each protocol" - ); - reportGaps(); - return undefined; - } const wanted = Object.entries(ROUTE_FOR_PLANE_PROTOCOL).flatMap( ([plane, entry]) => Object.entries(entry.routes) @@ -1102,7 +1093,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 +1101,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 +1157,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 +1189,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 +1202,10 @@ export const registerResponsesProvider = async ( return () => { bridgeDispose?.(); - for (const handle of captured) { - releaseRegistration(handle); - } - captured.length = 0; - void (async () => { - try { - await fiber?.dispose?.(); - } catch { - // A disposal failure must not mask the plugin's own teardown. - } - })(); + releaseMount(); }; } catch (error) { + releaseMount(); ctx.logger?.info?.( "[dsh-opencode-patch] internal routes stay unregistered (%s); routes declared in the profile are unaffected", error instanceof Error ? error.message : String(error) diff --git a/src/session-cost.ts b/src/session-cost.ts index d0086f64..082a1ad7 100644 --- a/src/session-cost.ts +++ b/src/session-cost.ts @@ -266,12 +266,20 @@ export const recordTurnUsage = ( turns: existing.turns + 1, }; + // Delete first, so the session moves to the END of the map. `Map.set` on an + // existing key keeps its original position, which made "the latest session" + // the most recently CREATED one: after sessions A then B, a new turn in A + // still read back as B's spend. + sessionStore.delete(sessionId); sessionStore.set(sessionId, updated); return toSnapshot(updated); }; -/** Read accumulated token and dollar usage for a session (or the latest active session). */ +/** + * Read accumulated token and dollar usage for a session, or for the session + * that most recently ran a turn when the id is absent or unknown. + */ export const getSessionUsage = ( sessionId?: string ): SessionUsageSnapshot | undefined => { diff --git a/src/settings-card.tsx b/src/settings-card.tsx index 773d4203..07d5a248 100644 --- a/src/settings-card.tsx +++ b/src/settings-card.tsx @@ -156,7 +156,7 @@ const PurgeRow: React.FC<{
= { groupQuota: "配额计量", groupRequests: "网关请求", endpoint: "API 端点", - endpointAuto: "自动(跟随目录)", + endpointAuto: "自动(同新端点)", endpointHint: - "请求发往哪个端点家族。自动会跟随网关目录给出的地址;另外两项则固定其一。旧端点与新端点的授权彼此独立且目前均可访问,因此请选择账户可用的那个,而不是当前最新的那个。", + "请求发往哪个端点家族。新端点按协议各自发往 inference 路径(inference/openai、inference/anthropic、inference/google;Go 为 inference/go/…);自动与新端点相同;旧端点固定使用较早的 zen/v1(Go 为 zen/go/v1)。两套端点的授权彼此独立且目前均可访问,因此请选择账户可用的那个。", endpointLegacy: "旧端点(zen/v1)", endpointModern: "新端点(inference/…)", freeModel: "免费", diff --git a/src/settings-fields.ts b/src/settings-fields.ts index dd6f19bd..92db2a2a 100644 --- a/src/settings-fields.ts +++ b/src/settings-fields.ts @@ -54,7 +54,16 @@ export const CONFIG_ONLY_FIELDS: readonly { }, { field: "debug", - reason: "diagnostic JSONL logging, not a behaviour a user tunes in the UI", + reason: + "diagnostic console logging of streamed calls, not a behaviour a user " + + "tunes in the UI", + }, + { + field: "debugFile", + reason: + "the JSONL file both debug halves append to; naming it writes the file " + + "whether or not debug is on, and a server-side path is a deployment fact " + + "rather than a card control", }, { field: "userAgent", @@ -99,12 +108,6 @@ export const CONFIG_ONLY_FIELDS: readonly { "per-model wire routing (api and baseURL); it overrides what the catalog " + "computed, which is a deployment concern rather than a card control", }, - { - field: "internalRoutes", - reason: - "whether to mount the per-protocol routes; off is for a deployment that " + - "declares its own provider entries for those protocols", - }, { field: "usageBaseURL", reason: "endpoint override; auto-discovered from the composition", diff --git a/src/settings-page.tsx b/src/settings-page.tsx index b6c9898c..7ded198e 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -15,6 +15,7 @@ import { } from "@deepseek-ai/dsh-client-ui-primitives"; import { DEFAULT_PROVIDERS, readStringList } from "./config-values.ts"; +import { storeRemote } from "./opencode-store-contract.ts"; import { purgeRemote } from "./purge-contract.ts"; import { OpencodeCard } from "./settings-card.tsx"; import { en, withBundleCopy, zh, type CopyKey } from "./settings-copy.ts"; @@ -51,14 +52,20 @@ export const PKG = "dsh-opencode-patch"; * * It is a contribution rather than a bare descriptor list because Typert * enforces unique package names, and this is the one place the package is - * registered. The meter's namespace is the only one this build mounts — the - * sign-in row and its `opencodeLogin` namespace are v2 work. + * registered. It carries every namespace the host mounts — the meter, the purge + * and the store inspection — and `test/remote-contract.test.ts` fails when a + * host service declares a method this list does not. The sign-in row and its + * `opencodeLogin` namespace are v2 work. */ export const patchRemote = { // ONE contribution, TWO namespaces. Typert enforces a unique package name per // contribution, so mounting them separately with the same package throws // "already registered" and drops the remote entirely. - descriptors: [...usageRemote.descriptors, ...purgeRemote.descriptors], + descriptors: [ + ...usageRemote.descriptors, + ...purgeRemote.descriptors, + ...storeRemote.descriptors, + ], package: PKG, }; @@ -66,8 +73,8 @@ export const patchRemote = { * The scoped npm name — published from this same tree, and the one a new * install gets. It must answer to the SAME pages as `PKG` or the settings card * never appears for whoever installed the name we tell them to. The retired - * `@viztor/dsh-opencode` is deliberately absent: it is a deprecated wrapper - * with no client half (see AGENTS.md, "Package vs component"). + * `@viztor/dsh-opencode` is deliberately absent: it is no longer published, + * and its last version was a wrapper with no client half. */ export const SCOPED_PKG = "@viztor/dsh-opencode-patch"; diff --git a/src/settings-purge.ts b/src/settings-purge.ts index 07d34ac7..cb37bbc1 100644 --- a/src/settings-purge.ts +++ b/src/settings-purge.ts @@ -52,7 +52,8 @@ export const purgeDeprecatedModels = async ( // **The host's own failure comes first.** The host answers a failure as // `{ ok: false, … }`, and reading that as "not a purge result" replaced the // message that says WHY with a sentence about shape — which tells the reader - // nothing they can act on. + // nothing they can act on. The remote protocol sends `{ ok: false, error }` + // with `error` a serialized `RemoteError`, so its `message` is read too. if (isRecord(value) && value.ok === false) { const { error, message: said } = value; let message = "the host refused the purge"; @@ -60,6 +61,11 @@ export const purgeDeprecatedModels = async ( message = said; } else if (typeof error === "string") { message = error; + } else if (isRecord(error)) { + const { message: reason } = error; + if (typeof reason === "string") { + message = reason; + } } return { kind: "failed", message }; } diff --git a/src/settings-store-inspect.tsx b/src/settings-store-inspect.tsx index 31533df3..2e529c44 100644 --- a/src/settings-store-inspect.tsx +++ b/src/settings-store-inspect.tsx @@ -247,7 +247,7 @@ export const StoreInspectRow: React.FC = (props) => {
( + read: () => Promise, + attempts = 5, + sleep?: (attempt: number) => Promise, + live: () => boolean = () => true +): Promise => + retryUntilAnswered(read, { + attempts, + live, + sleep: + sleep ?? + backoffOver((callback, delayMs) => setTimeout(callback, delayMs)), + warn: (message, error) => { + console.warn(message, error); + }, + }); /** The container: reads once when the card appears, on window focus, or via manual refresh. */ export const UsageSummary: React.FC = ({ diff --git a/src/stream-hook.ts b/src/stream-hook.ts index 4b6ab25e..21aa046b 100644 --- a/src/stream-hook.ts +++ b/src/stream-hook.ts @@ -41,7 +41,6 @@ import { isAsyncIterableLike, isRecord } from "./utils/guards.ts"; interface StreamOptions { messages?: unknown; - prepared?: unknown; model?: unknown; parentId?: unknown; parentSession?: unknown; @@ -197,10 +196,6 @@ export const createStreamHook = ( // nothing about the response has to be rewritten. Returning here rather than // continuing means the redirected call does the session/debug/turn-state // work exactly once — its own pass through this hook. - // - // `prepared` is deliberately dropped. It is bound to the SOURCE route's - // adapter and already-resolved model, so re-resolving on the target route is - // not a loss — it is the only correct thing to do. const redirect = internalRouteFor( providerKey, options.model, @@ -255,7 +250,6 @@ export const createStreamHook = ( if (redirect === GO_GOOGLE_INTERNAL_ROUTE) { const stream = ctx.llm.stream({ ...options, - prepared: undefined, provider: redirect, messages: googleHistoryFor(options.messages, providerKey, redirect), }); @@ -349,11 +343,12 @@ export const createStreamHook = ( } const modelProp: unknown = options.model; const modelName = typeof modelProp === "string" ? modelProp : "unknown"; - // **The plane's own price, not the other plane's.** The two planes price the - // same id differently — `deepseek-v4.1-flash` is $0.30/$1.20 on Zen and - // $0.15/$0.60 on Go — and the plane-agnostic lookup searches Go FIRST, so a - // Zen turn was priced at the Go rate: half of what it cost. The plane - // decides, because the route is what the user configured. + // **The plane's own price, not the other plane's.** The two planes price + // some shared ids differently — one costs twice as much on Zen as on Go — + // and the plane-agnostic lookup searches Go FIRST, so a Zen turn was priced + // at the Go rate: half of what it cost. The plane decides, because the + // route is what the user configured; it is the same lookup the dispatch + // above makes. // // The fallback is for a model the plane's own catalog does not describe, // where any price is better than none — and the turn is still attributed to diff --git a/src/tool-fallback.ts b/src/tool-fallback.ts index 43d6f366..bcd25fc9 100644 --- a/src/tool-fallback.ts +++ b/src/tool-fallback.ts @@ -22,55 +22,6 @@ import { isGoPlaneUrl } from "./utils/request-shape.ts"; /** Path segment that marks a Responses-API body worth inspecting. */ export const RESPONSES_PATH = "/responses"; -/** Placeholder `read` schema formatted for Anthropic-compatible /messages. */ -export const DUMMY_READ_TOOL_ANTHROPIC = { - description: "Read a file or directory from the local filesystem.", - input_schema: { - properties: { - filePath: { - description: "The absolute path to the file or directory to read", - type: "string", - }, - offset: { - description: "The line number to start reading from (1-indexed)", - type: "number", - }, - limit: { - description: "The maximum number of lines to read (defaults to 2000)", - type: "number", - }, - }, - required: ["filePath"], - type: "object", - }, - name: "read", -} as const; - -/** Placeholder `bash` schema formatted for Anthropic-compatible /messages. */ -export const DUMMY_BASH_TOOL_ANTHROPIC = { - description: "Execute a bash command.", - input_schema: { - properties: { - command: { - description: "The command to execute", - type: "string", - }, - workdir: { - description: - "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.", - type: "string", - }, - timeout: { - description: "Optional timeout in milliseconds", - type: "number", - }, - }, - required: ["command"], - type: "object", - }, - name: "bash", -} as const; - /** Placeholder `read` schema the gateway's validator accepts. */ export const DUMMY_READ_TOOL = { description: "Read a file or directory from the local filesystem.", @@ -207,7 +158,7 @@ const toolNames = ( * * **Pure, so the row can be constructed.** The plane-aware lookup that produces * `row` cannot be exercised against a divergence the vendor has not produced - * yet — see `test/fixtures/catalogs.ts` — so the decision takes the row and the + * yet — see `test/support/catalogs.ts` — so the decision takes the row and the * lookup moves to the caller. * * @param row - the model's row on ITS plane, or `undefined` when that plane's @@ -278,7 +229,9 @@ export const maybeInjectCoreTools = ( return body; } // Both JSON routes: the gateway checks read+bash on /responses AND - // /chat/completions (a toolless completions body 403s the same way). + // /chat/completions (a toolless completions body 403s the same way). An + // Anthropic `/messages` URL names neither, so it never gets this far — which + // is why there are no Anthropic-dialect schemas below. const isResponses = url.includes(RESPONSES_PATH); const isCompletions = url.includes("/chat/completions") || url.includes("/completions"); @@ -331,36 +284,15 @@ export const maybeInjectCoreTools = ( tools = []; } const { hasBash, hasRead, isOpenAiFormat } = toolNames(tools); - const isAnthropic = url.includes("/messages"); - const useOpenAi = - isOpenAiFormat || - url.includes("/chat/completions") || - url.includes("/completions"); - - const getReadTool = () => { - if (isAnthropic) { - return DUMMY_READ_TOOL_ANTHROPIC; - } - if (useOpenAi) { - return DUMMY_READ_TOOL_FUNCTION; - } - return DUMMY_READ_TOOL; - }; - const getBashTool = () => { - if (isAnthropic) { - return DUMMY_BASH_TOOL_ANTHROPIC; - } - if (useOpenAi) { - return DUMMY_BASH_TOOL_FUNCTION; - } - return DUMMY_BASH_TOOL; - }; + const useOpenAi = isOpenAiFormat || isCompletions; + const readTool = useOpenAi ? DUMMY_READ_TOOL_FUNCTION : DUMMY_READ_TOOL; + const bashTool = useOpenAi ? DUMMY_BASH_TOOL_FUNCTION : DUMMY_BASH_TOOL; if (!hasRead) { - tools.push(getReadTool()); + tools.push(readTool); } if (!hasBash) { - tools.push(getBashTool()); + tools.push(bashTool); } parsed.tools = tools; const newBodyStr = JSON.stringify(parsed); diff --git a/src/usage-ui.ts b/src/usage-ui.ts index 727da1ca..b51358fe 100644 --- a/src/usage-ui.ts +++ b/src/usage-ui.ts @@ -687,7 +687,9 @@ const durationText = ( } const zh = locale?.startsWith("zh") === true; const units = zh - ? { d: "天", h: "小时", m: "分" } + ? // `分钟`, not `分`: the platform's narrow zh output, so a row reads the + // same on an engine without the constructor as on one with it. + { d: "天", h: "小时", m: "分钟" } : { d: "d", h: "h", m: "m" }; const out: string[] = []; if (parts.days !== undefined && parts.days > 0) { diff --git a/src/utils/retry.ts b/src/utils/retry.ts index 11a7c5e9..8dcaca05 100644 --- a/src/utils/retry.ts +++ b/src/utils/retry.ts @@ -2,11 +2,47 @@ * Generic retry loop over an asynchronous operation that may return undefined or throw. * * This is a pure algorithm: it takes the operation, the retry limit, the sleep - * mechanism and an optional liveness probe. No context, no globals, no I/O. + * between attempts, an optional liveness probe and an optional logger as + * parameters. No context, no globals, no I/O — and no clock: the caller owns the + * wait, and {@link backoffOver} builds the usual one over a timer the caller + * supplies. * * @module dsh-opencode-patch/retry */ +/** A `setTimeout`-shaped scheduler: run `callback` after `delayMs`. */ +export type RetryTimer = (callback: () => void, delayMs: number) => unknown; + +/** How {@link retryUntilAnswered} retries, and what it may touch. */ +export interface RetryOptions { + /** Maximum number of attempts (defaults to 5). */ + attempts?: number; + /** Polled before each attempt; returning false aborts. */ + live?: () => boolean; + /** + * Takes the attempt index (0-based) and resolves after the delay. Required, + * because a utility has no clock of its own: pass `backoffOver(timer)` for the + * default schedule. + */ + sleep: (attempt: number) => Promise; + /** Told when every attempt failed with an error; omitted means silent. */ + warn?: (message: string, error: unknown) => void; +} + +/** + * The usual schedule: a linear backoff of `baseMs` per attempt, on `timer`. + * + * @param timer - the scheduler the delay runs on. + * @param baseMs - the first delay; attempt `n` waits `baseMs * (n + 1)`. + * @returns a `sleep` for {@link RetryOptions}. + */ +export const backoffOver = + (timer: RetryTimer, baseMs = 300) => + (attempt: number): Promise => + new Promise((resolve) => { + timer(resolve, baseMs * (attempt + 1)); + }); + /** * Attempt to read a defined value until an answer arrives or attempts are exhausted. * @@ -18,20 +54,17 @@ * - If all attempts are exhausted, it returns `undefined`. * * @param read - the async reader operation. - * @param attempts - maximum number of attempts (defaults to 5). - * @param sleep - callback taking attempt index (0-based) and returning a sleep Promise. - * @param live - optional predicate polled before each attempt; returning false aborts. + * @param options - the attempt limit, the sleep, the liveness probe and the + * logger, all owned by the caller. * @returns the first defined answer, or `undefined` if unfulfilled. */ export const retryUntilAnswered = async ( read: () => Promise, - attempts = 5, - sleep: (attempt: number) => Promise = (attempt) => - new Promise((resolve) => { - setTimeout(resolve, 300 * (attempt + 1)); - }), - live: () => boolean = () => true + options: RetryOptions ): Promise => { + const attempts = options.attempts ?? 5; + const live = options.live ?? (() => true); + const { sleep } = options; let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt += 1) { if (!live()) { @@ -52,7 +85,10 @@ export const retryUntilAnswered = async ( } } if (lastError !== undefined) { - console.warn("[dsh-opencode-patch] retry: all attempts failed", lastError); + options.warn?.( + "[dsh-opencode-patch] retry: all attempts failed", + lastError + ); } return undefined; }; diff --git a/test/adapter-replacement.test.ts b/test/adapter-replacement.test.ts deleted file mode 100644 index d83ecada..00000000 --- a/test/adapter-replacement.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * `replaceRouteAdapters` — taking a provider route over from the adapter that - * currently serves it. - * - * The duplicate check in `dsh-llm` reads the registry map, so releasing the entry - * first makes the route registrable through the ordinary API. These cases pin the - * three things that must hold for that swap to be safe: the original is restored on - * disposal, a failed build leaves it untouched, and a refusal does not strand the - * provider. - */ -import { describe, expect, it, vi } from "vitest"; - -import { replaceRouteAdapters } from "../src/adapter-replacement.ts"; -import type { CordisContext } from "../src/index.ts"; - -interface FakeLlm { - adapters: Map }>; - registerAdapter: ( - providers: readonly string[], - adapter: unknown - ) => (() => void) | undefined; -} - -/** A registry with one built-in route, and the events a swap would cause. */ -const registryWith = (route: string) => { - const original = { adapter: { name: "builtin" } as Record }; - const adapters = new Map([[route, original]]); - const events: string[] = []; - const llm: FakeLlm = { - adapters, - registerAdapter: (providers, adapter) => { - for (const provider of providers) { - if (adapters.has(provider)) { - throw new Error("DUPLICATE_ADAPTER"); - } - } - for (const provider of providers) { - adapters.set(provider, { adapter: adapter as Record }); - } - events.push(`registered:${providers.join(",")}`); - return () => { - for (const provider of providers) { - adapters.delete(provider); - } - events.push(`disposed:${providers.join(",")}`); - }; - }, - }; - return { adapters, events, llm, original }; -}; - -describe("replaceRouteAdapters", () => { - it("releases the route, registers ours, and restores the original after", () => { - const { adapters, events, llm, original } = registryWith("opencode"); - const ctx = { llm, logger: { warn: () => {} } } as unknown as CordisContext; - const ours = { name: "ours" }; - - const stop = replaceRouteAdapters(ctx, ["opencode"], () => ours); - - expect(stop).toBeTypeOf("function"); - expect(adapters.get("opencode")?.adapter).toBe(ours); - expect(events).toEqual(["registered:opencode"]); - - stop?.(); - expect(adapters.get("opencode")).toBe(original); - expect(events).toEqual(["registered:opencode", "disposed:opencode"]); - }); - - it("does nothing when the route is not registered", () => { - // A composition without the provider has no route to take over, and inventing - // one would give the user a provider the deployment never declared. - const { adapters, events, llm } = registryWith("somewhere-else"); - const ctx = { llm, logger: { warn: () => {} } } as unknown as CordisContext; - expect( - replaceRouteAdapters(ctx, ["opencode"], () => ({ name: "ours" })) - ).toBeUndefined(); - expect(adapters.has("opencode")).toBe(false); - expect(events).toEqual([]); - }); - - it("leaves the route with its own adapter when the build fails", () => { - const { adapters, llm, original } = registryWith("opencode"); - const warn = vi.fn(); - const ctx = { llm, logger: { warn } } as unknown as CordisContext; - const stop = replaceRouteAdapters(ctx, ["opencode"], () => { - throw new Error("no pi-ai here"); - }); - expect(stop).toBeUndefined(); - expect(adapters.get("opencode")).toBe(original); - expect(warn).toHaveBeenCalled(); - }); - - it("restores the original when the host refuses the registration", () => { - const { adapters, llm, original } = registryWith("opencode"); - // A refusal AFTER the release: the provider would otherwise be left unserved. - llm.registerAdapter = () => { - throw new Error("refused"); - }; - const warn = vi.fn(); - const ctx = { llm, logger: { warn } } as unknown as CordisContext; - expect( - replaceRouteAdapters(ctx, ["opencode"], () => ({ name: "ours" })) - ).toBeUndefined(); - expect(adapters.get("opencode")).toBe(original); - expect(warn).toHaveBeenCalled(); - }); -}); - -describe("replaceRouteAdapters: the shapes it declines", () => { - const ctxWith = (llm: unknown) => - ({ llm, logger: { warn: vi.fn() } }) as unknown as CordisContext; - - it("declines a registry without a usable map or registerAdapter", () => { - // Both are read structurally off the service, so a composition that exposes - // one and not the other must answer "cannot take over" rather than throwing - // halfway through a swap. - expect( - replaceRouteAdapters(ctxWith({}), ["opencode"], () => ({})) - ).toBeUndefined(); - expect( - replaceRouteAdapters( - ctxWith({ adapters: {}, registerAdapter: () => {} }), - ["opencode"], - () => ({}) - ) - ).toBeUndefined(); - expect( - replaceRouteAdapters( - ctxWith({ adapters: new Map(), registerAdapter: "nope" }), - ["opencode"], - () => ({}) - ) - ).toBeUndefined(); - // A context with no `llm` at all is the same answer. - expect( - replaceRouteAdapters({} as CordisContext, ["opencode"], () => ({})) - ).toBeUndefined(); - }); - - it("declines when any route of the group is unserved", () => { - // All-or-nothing: replacing half a protocol group would leave the other - // half on the host's adapter, and the two would disagree about the route. - const { llm } = registryWith("opencode"); - expect( - replaceRouteAdapters(ctxWith(llm), ["opencode", "opencode-go"], () => ({ - name: "ours", - })) - ).toBeUndefined(); - }); - - it("declines an empty route list", () => { - const { llm } = registryWith("opencode"); - expect(replaceRouteAdapters(ctxWith(llm), [], () => ({}))).toBeUndefined(); - }); - - it("gives the routes back when the host returns no disposer", () => { - // `registerAdapter` answers a callable or nothing. A composition that - // answers nothing still has to be reversible, or the route is stranded on - // our adapter with no way back. - const original = { - adapter: { name: "builtin" } as Record, - }; - const adapters = new Map([["opencode", original]]); - const llm = { - adapters, - registerAdapter: (providers: readonly string[], adapter: unknown) => { - for (const provider of providers) { - adapters.set(provider, { - adapter: adapter as Record, - }); - } - // Falls off the end: one of the two shapes the host answers with is no - // disposer at all, and a composition that answers that still has to be - // reversible. - }, - }; - const dispose = replaceRouteAdapters(ctxWith(llm), ["opencode"], () => ({ - name: "ours", - })); - expect(dispose).toBeDefined(); - // Ours is in place... - expect(adapters.get("opencode")?.adapter.name).toBe("ours"); - dispose?.(); - // ...and the original is back even though the host gave us no disposer. - expect(adapters.get("opencode")).toBe(original); - }); - - it("restores the original even if its own disposer throws", () => { - // Disposal runs inside a `finally`, so a throwing disposer cannot leave the - // route without its adapter. - const original = { - adapter: { name: "builtin" } as Record, - }; - const adapters = new Map([["opencode", original]]); - const llm = { - adapters, - registerAdapter: (providers: readonly string[], adapter: unknown) => { - for (const provider of providers) { - adapters.set(provider, { - adapter: adapter as Record, - }); - } - return () => { - throw new Error("dispose exploded"); - }; - }, - }; - const dispose = replaceRouteAdapters(ctxWith(llm), ["opencode"], () => ({ - name: "ours", - })); - expect(() => dispose?.()).toThrow("dispose exploded"); - expect(adapters.get("opencode")).toBe(original); - }); -}); diff --git a/test/architecture.test.ts b/test/architecture.test.ts index 53c1f050..c9b79449 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -13,25 +13,87 @@ * * @module test/architecture.test */ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, posix } from "node:path"; import { describe, expect, it } from "vitest"; -const UTILS_DIR = join(import.meta.dirname, "..", "src", "utils"); +import { blankCommentLines } from "./support/strip-comments.ts"; -/** Every `.ts` file directly inside `src/utils/`. */ -const utilityFiles = (): string[] => - readdirSync(UTILS_DIR) - .filter((name) => name.endsWith(".ts")) - .toSorted(); +const SRC_DIR = join(import.meta.dirname, "..", "src"); +const UTILS_DIR = join(SRC_DIR, "utils"); -/** The source with `//` and block comments removed, keeping line structure. */ +/** + * Every `.ts`/`.tsx` file under `dir`, at any depth, relative to `dir`. + * + * Recursive on purpose: a utility in a subfolder is still a utility, and a walk + * that read one level would let `utils/nested/` hold anything at all. + */ +const filesUnder = (dir: string): string[] => { + const files: string[] = []; + const walk = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const full = join(current, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (/\.tsx?$/u.test(entry.name)) { + files.push(full.slice(dir.length + 1).replaceAll("\\", "/")); + } + } + }; + walk(dir); + return files.toSorted(); +}; + +/** Every utility, at any depth under `src/utils/`. */ +const utilityFiles = (): string[] => filesUnder(UTILS_DIR); + +/** + * The source with its comments blanked, keeping line structure: comment lines + * through {@link blankCommentLines}, which only ever errs loudly, then a + * trailing `// …` after code. The trailing strip needs a space before the + * `//`, so a `"https://…"` string is not cut short. + */ const stripComments = (source: string): string => - source - .replaceAll(/\/\*[\s\S]*?\*\//gu, "") - .replaceAll(/^\s*\/\/.*$/gmu, "") - .replaceAll(/\/\/.*$/gmu, ""); + blankCommentLines(source).replaceAll(/(?<=\s)\/\/.*$/gmu, ""); + +/** + * Runtime imports of one file. A type-only import is erased by the compiler, + * so it is no edge in the bundle and is skipped; a mixed one is a real edge. + */ +const runtimeImportsOf = (source: string): string[] => { + const specifiers: string[] = []; + for (const match of source.matchAll( + /\b(?:import|export)\s+(type\s+)?(?:[^;"']*?\sfrom\s+)?"([^"]+)"/gu + )) { + if (match[1] === undefined && match[2] !== undefined) { + specifiers.push(match[2]); + } + } + return specifiers; +}; + +/** + * Every `src/` file `entry` reaches at runtime, entry included, mapped to its + * source with comments stripped. + */ +const runtimeGraph = (entry: string): Map => { + const seen = new Map(); + const visit = (file: string): void => { + if (seen.has(file)) { + return; + } + const source = stripComments(readFileSync(join(SRC_DIR, file), "utf8")); + seen.set(file, source); + for (const specifier of runtimeImportsOf(source)) { + if (specifier.startsWith("./") || specifier.startsWith("../")) { + visit(join(file, "..", specifier).replaceAll("\\", "/")); + } + } + }; + visit(entry); + return seen; +}; /** The module specifiers one file imports. */ const importsOf = (source: string): string[] => { @@ -63,13 +125,29 @@ const FORBIDDEN: readonly { "a cordis context is the world: a utility takes its inputs as arguments instead", matches: (s) => s.startsWith("@deepseek-ai/cordis"), }, - { - because: - "only guards.ts and other utilities may be imported; anything else is an effect module", - matches: (s) => s.startsWith("../") || s.startsWith("./../"), - }, ]; +/** + * Why a relative import from the utility at `file` (a path relative to + * `src/utils/`) leaves the folder, or `undefined` when it stays inside. + * + * The specifier is resolved against the importing file's own directory, so a + * nested utility's `../guards.ts` is a sibling, not an escape. Comparing the raw + * specifier would flag every `../` — which, once the walk is recursive, rejects + * exactly the nested utilities the recursion admits. + */ +const escapesUtils = (file: string, specifier: string): string | undefined => { + if (!specifier.startsWith("./") && !specifier.startsWith("../")) { + return undefined; + } + // Resolved under a root named for the folder, so a path that leaves and + // re-enters it (`../../utils/guards.ts`) is judged by where it lands. + const target = posix.join("/utils", posix.dirname(file), specifier); + return target.startsWith("/utils/") + ? undefined + : "only guards.ts and other utilities may be imported; anything else is an effect module"; +}; + describe("architecture · a property read is a declared dependency", () => { const SRC = join(import.meta.dirname, "..", "src"); @@ -133,21 +211,7 @@ describe("architecture · a property read is a declared dependency", () => { return [...names]; }; - const sourceFiles = (): string[] => { - const files: string[] = []; - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (/\.tsx?$/u.test(entry.name)) { - files.push(full.slice(SRC.length + 1)); - } - } - }; - walk(SRC); - return files; - }; + const sourceFiles = (): string[] => filesUnder(SRC); it("reads no host service as a property except the declared `llm`", () => { // Cordis THROWS `cannot get property "X" without inject` for an undeclared @@ -170,7 +234,7 @@ describe("architecture · a property read is a declared dependency", () => { }); it("declares `llm`, the one service the plugin cannot work without", async () => { - const { inject } = await import("../src/identity.ts"); + const { inject } = await import("./support/src.ts"); expect(inject).toContain("llm"); }); }); @@ -205,6 +269,71 @@ describe("architecture · no source module imports a test library", () => { }); }); +describe("architecture · src never reads what the desktop's patched pi changes", () => { + /** + * The harness's own pi patch, which only the desktop app ships, removes pi's + * per-delta re-parse of streamed tool-call arguments and round-trips a model's + * thought signature on OpenAI-compatible completions. An npm install runs pi + * unpatched. The plugin supports both because it reads neither (D14''): + * this case fails the day a module starts to, so the difference stays + * invisible to the plugin instead of becoming a channel-dependent bug. + * + * The names are the patch's own, and one shape that names none of them: + * the patch deletes the line that refreshes an unfinished tool-call block's + * `arguments`, and a caller reaches that block through the `partial` + * message pi attaches to every streaming event (text and thinking deltas + * included). So a `.partial` property read (dotted, optional-chained or + * bracketed) counts too; a `.partial()` call and the `Partial<…>` type do + * not, since neither touches a stream event. + */ + const PATCHED_SURFACE = + /\b(?:partialJson|partial_json|partialArgs|toolcall_delta|toolCallDelta|parseStreamingJson|thought_signature|thoughtSignature)\b|\.partial\b(?!\s*\()|\[\s*["'`]partial["'`]\s*\]/u; + + const readsPatchedSurface = (source: string): boolean => + PATCHED_SURFACE.test(stripComments(source)); + + it("no src module reads a streamed tool-call delta, partial arguments or a thought signature", () => { + const files = filesUnder(SRC_DIR); + expect(files.length).toBeGreaterThan(10); + expect( + files.filter((file) => + readsPatchedSurface(readFileSync(join(SRC_DIR, file), "utf8")) + ) + ).toEqual([]); + }); + + it("flags a planted read, in code but not in a comment, so a pass means something", () => { + expect( + readsPatchedSurface('if (event.type === "toolcall_delta") {}\n') + ).toBe(true); + expect(readsPatchedSurface("const json = block.partialJson;\n")).toBe(true); + expect(readsPatchedSurface("part.thoughtSignature ??= signature;\n")).toBe( + true + ); + expect( + readsPatchedSurface("// a comment may name partialJson\nconst a = 1;\n") + ).toBe(false); + // The in-progress arguments through the event's `partial` message, which + // read parsed on an npm install and `{}` on the desktop's patched pi. + expect( + readsPatchedSurface("const args = event.partial.content[0].arguments;\n") + ).toBe(true); + expect( + readsPatchedSurface("const blocks = event?.partial?.content ?? [];\n") + ).toBe(true); + expect(readsPatchedSurface('const message = event["partial"];\n')).toBe( + true + ); + // Neither a `.partial()` call nor the `Partial<…>` type is a stream read. + expect(readsPatchedSurface("const loose = schema.partial();\n")).toBe( + false + ); + expect( + readsPatchedSurface("const row: Partial> = {};\n") + ).toBe(false); + }); +}); + describe("architecture · utils/ holds only pure functions", () => { it("has utilities to check", () => { // Without this the suite passes vacuously on an empty folder — the exact @@ -212,26 +341,57 @@ describe("architecture · utils/ holds only pure functions", () => { expect(utilityFiles().length).toBeGreaterThan(0); }); + it("strips comments without cutting code short, keeping the line count", () => { + // Stripping only ever removes text and every gate asserts an empty list, + // so over-stripping can never fail a gate; these answers pin the rule. + expect(stripComments('const u = "https://x"; process.env.A')).toContain( + "process.env.A" + ); + expect(stripComments("f(); // process.env")).not.toMatch(/process\.env/u); + expect( + stripComments("/**\n * process.env\n */\nconst a = 1;").split("\n") + ).toEqual(["", "", "", "const a = 1;"]); + expect(stripComments("/* a */ fetch(x)")).toContain("fetch("); + expect(stripComments("f(/* process.env */ 1)")).toContain("process.env"); + }); + it("imports nothing host-only, and nothing outside utils/", () => { const violations: string[] = []; for (const file of utilityFiles()) { const source = readFileSync(join(UTILS_DIR, file), "utf8"); for (const specifier of importsOf(source)) { - // `guards.ts` is dependency-free by construction and is the one - // non-utility a utility may share. - if (specifier === "./guards.ts") { - continue; - } for (const rule of FORBIDDEN) { if (rule.matches(specifier)) { violations.push(`${file} imports "${specifier}" — ${rule.because}`); } } + // `guards.ts` lives in utils/ and is dependency-free by construction, + // so it resolves inside the folder like any other utility. + const escape = escapesUtils(file, specifier); + if (escape !== undefined) { + violations.push(`${file} imports "${specifier}" — ${escape}`); + } } } expect(violations).toEqual([]); }); + it("resolves an import from the importing file's folder, at any depth", () => { + // Known answers, so the rule is seen to allow and to reject. + expect(escapesUtils("retry.ts", "./guards.ts")).toBeUndefined(); + expect(escapesUtils("nested/a.ts", "../guards.ts")).toBeUndefined(); + expect(escapesUtils("nested/a.ts", "./b.ts")).toBeUndefined(); + expect(escapesUtils("nested/a.ts", "../retry.ts")).toBeUndefined(); + expect(escapesUtils("retry.ts", "../config.ts")).toBeDefined(); + expect(escapesUtils("retry.ts", "./../config.ts")).toBeDefined(); + expect(escapesUtils("nested/a.ts", "../../config.ts")).toBeDefined(); + expect( + escapesUtils("nested/a.ts", "../../utils/guards.ts") + ).toBeUndefined(); + expect(escapesUtils("retry.ts", "../utils/guards.ts")).toBeUndefined(); + expect(escapesUtils("nested/a.ts", "../../utilsx/a.ts")).toBeDefined(); + }); + it("never reads process.env, a global the arguments could have carried", () => { // Comments are stripped first: prose ABOUT the environment is exactly what // a rule explaining itself looks like, and a rule that flags its own @@ -253,4 +413,411 @@ describe("architecture · utils/ holds only pure functions", () => { }); expect(violations).toEqual([]); }); + + /** + * A reference to the console or to a global timer, outside a comment — not + * only a call: `timer = setTimeout` as a default parameter, `backoffOver(setTimeout)` + * and `const log = console.warn` reach the global just as surely, and so do + * the four spellings of the global object (`globalThis.`, `window.`, + * `global.`, `self.`). A member of something else (`options.timer(`, + * `x.setTimeout(`) is not the global. + * + * A tripwire for the honest spelling, not a sandbox: an alias + * (`const g = globalThis; g.setTimeout(…)`) or bracket access on the global + * object gets past it, and catching those is a type-aware lint's job. + */ + const AMBIENT_EFFECT = + /(? { + // A log line is output and a timer is a clock: both are the world, and a + // utility that needs one takes it as a parameter, the way `retry.ts` takes + // its `sleep` and its `warn` and leaves the timer to the caller. + const violations: string[] = []; + for (const file of utilityFiles()) { + // Block comments are blanked rather than removed, so a line number in a + // violation is the line in the file. + const source = stripComments(readFileSync(join(UTILS_DIR, file), "utf8")); + for (const [index, line] of source.split("\n").entries()) { + const match = AMBIENT_EFFECT.exec(line); + if (match !== null) { + violations.push( + `${file}:${index + 1} — ${match[0].trim()} is an effect the caller owns` + ); + } + } + } + expect(violations).toEqual([]); + }); + + it("knows a console call and a timer when it sees one, and walks every level", () => { + // Known answers, so an empty violation list means the rule ran. + expect(AMBIENT_EFFECT.test('console.warn("x")')).toBe(true); + expect(AMBIENT_EFFECT.test("setTimeout(f, 1)")).toBe(true); + expect(AMBIENT_EFFECT.test("globalThis.setInterval(f, 1)")).toBe(true); + expect(AMBIENT_EFFECT.test("global.setTimeout(f, 1)")).toBe(true); + expect(AMBIENT_EFFECT.test("self.console.warn(x)")).toBe(true); + expect(AMBIENT_EFFECT.test("setImmediate(f)")).toBe(true); + expect(AMBIENT_EFFECT.test("clearTimeout(handle)")).toBe(true); + expect( + AMBIENT_EFFECT.test("(timer: RetryTimer = setTimeout, baseMs = 300) =>") + ).toBe(true); + expect(AMBIENT_EFFECT.test("const sleep = backoffOver(setTimeout);")).toBe( + true + ); + expect(AMBIENT_EFFECT.test("const log = console.warn;")).toBe(true); + expect(AMBIENT_EFFECT.test("const { warn } = console;")).toBe(true); + expect(AMBIENT_EFFECT.test('console["warn"](x)')).toBe(true); + expect(AMBIENT_EFFECT.test("(options.warn ?? console.warn)(m, e)")).toBe( + true + ); + expect(AMBIENT_EFFECT.test("options.timer(resolve, ms)")).toBe(false); + expect(AMBIENT_EFFECT.test("x.setTimeout(f, 1)")).toBe(false); + expect(AMBIENT_EFFECT.test("const consoleLike = 1;")).toBe(false); + expect( + AMBIENT_EFFECT.test( + "type RetryTimer = (callback: () => void, delayMs: number) => unknown" + ) + ).toBe(false); + expect(utilityFiles()).toContain("retry.ts"); + expect(filesUnder(SRC_DIR)).toContain("utils/retry.ts"); + }); +}); + +describe("architecture · the client bundle reaches nothing host-only", () => { + /** The client bundle's entry, as `vite.config.ts` names it. */ + const ENTRY = "settings-page.tsx"; + + /** Every `src/` file the entry reaches at runtime, entry included. */ + const clientGraph = (): Map => runtimeGraph(ENTRY); + + /** What a client module may not import, each with the reason it is host-only. */ + const HOST_ONLY: readonly { + readonly matches: (specifier: string) => boolean; + readonly because: string; + }[] = [ + { + because: "a node built-in does not exist in the browser", + matches: (s) => s.startsWith("node:"), + }, + { + because: + "the cordis runtime is the host's, and the client gets its own context", + matches: (s) => s === "@deepseek-ai/cordis", + }, + { + because: + "pi is loaded from the host's installation, never shipped to a page", + matches: (s) => s.startsWith("@earendil-works/pi-ai"), + }, + ]; + + it("walks a graph that actually contains the client modules", () => { + // A walker that resolved nothing would pass every rule below vacuously. + const graph = clientGraph(); + expect(graph.has(ENTRY)).toBe(true); + expect(graph.has("usage-pill.tsx")).toBe(true); + expect(graph.has("config-values.ts")).toBe(true); + // And it must not wander into the host half through a type-only import. + expect(graph.has("lifecycle.ts")).toBe(false); + }); + + it("imports no node built-in, no cordis runtime and no pi", () => { + const violations: string[] = []; + for (const [file, source] of clientGraph()) { + for (const specifier of runtimeImportsOf(source)) { + for (const rule of HOST_ONLY) { + if (rule.matches(specifier)) { + violations.push(`${file} imports "${specifier}" — ${rule.because}`); + } + } + } + } + expect(violations).toEqual([]); + }); + + it("never reads process.env, which the browser does not have", () => { + // The bundle test runs the built file in a VM, so a read inside a function + // that never runs there passes it. Reading the source closes that gap. + const violations = [...clientGraph()] + .filter(([, source]) => /\bprocess\.env\b/u.test(source)) + .map(([file]) => file); + expect(violations).toEqual([]); + }); + + it("flags a planted violation, so a pass means something", () => { + // The rule is run over a source string with a known answer. + const planted = + 'import { readFileSync } from "node:fs";\nimport type { X } from "node:os";'; + expect(runtimeImportsOf(planted)).toEqual(["node:fs"]); + expect(HOST_ONLY.some((rule) => rule.matches("node:fs"))).toBe(true); + }); +}); + +describe("architecture · the host bundle mutates no registry it does not own", () => { + /** The host half's composition root: everything `apply()` can run. */ + const ENTRY = "lifecycle.ts"; + + /** A write to an `adapters` map: the llm registry's private table. */ + const REGISTRY_WRITE = /\badapters\??\.(?:delete|set|clear)\s*\(/u; + + it("walks a graph that actually contains the host modules", () => { + const graph = runtimeGraph(ENTRY); + expect(graph.has("stream-hook.ts")).toBe(true); + expect(graph.has("responses-provider.ts")).toBe(true); + expect(graph.has("settings-page.tsx")).toBe(false); + expect(REGISTRY_WRITE.test('llm.adapters.delete("opencode")')).toBe(true); + expect(REGISTRY_WRITE.test('llm.adapters?.delete("opencode")')).toBe(true); + expect(REGISTRY_WRITE.test("this.adapters.get(route)")).toBe(false); + }); + + it("nothing reachable from lifecycle.ts deletes or sets an llm adapters entry", () => { + // `registerAdapter` is all-or-nothing and only the owner's handle moves a + // registration's routes. An entry deleted from outside leaves the owner + // believing it holds the route, and its next dispose deletes ours. The + // plugin keeps pi-ai off the planes through the config waterfall instead + // (docs/engineering-notes.md, "Replacing another plugin's adapter + // registration is not an API"). + const violations = [...runtimeGraph(ENTRY)] + .filter(([, source]) => REGISTRY_WRITE.test(source)) + .map(([file]) => file); + expect(violations).toEqual([]); + }); +}); + +describe("architecture · tests reach src through one door", () => { + const SUPPORT = join(import.meta.dirname, "support", "src.ts"); + + /** + * Whether `source` DECLARES `name` — an `export const|function|class|…` + * of it — rather than passing it on with `export { name } from "…"`. + */ + const declares = (source: string, name: string): boolean => + new RegExp( + String.raw`export (?:declare )?(?:abstract )?(?:async )?(?:const|let|function\*?|class|interface|type|enum) ${name}\b`, + "u" + ).test(source); + + /** Each `export { names } from "../../src/"` block, names unaliased. */ + const reExportBlocks = ( + source: string + ): { readonly target: string; readonly names: readonly string[] }[] => + [ + ...source.matchAll( + /^export (?:type )?\{([^}]*)\} from "\.\.\/\.\.\/src\/([^"]+)"/gmu + ), + ].map((match) => ({ + names: (match[1] ?? "") + .split(",") + .map((entry) => + entry + .trim() + .replace(/^type\s+/u, "") + .replace(/\s+as\s+[\w$]+$/u, "") + ) + .filter((entry) => entry !== ""), + target: match[2] ?? "", + })); + + it("knows a declaration from a pass-through when it sees one", () => { + // Known answers, so an empty miss list means the rule ran. + const config = readFileSync(join(SRC_DIR, "config.ts"), "utf8"); + expect(declares(config, "Config")).toBe(true); + expect(declares('export { Config } from "./config.ts";', "Config")).toBe( + false + ); + expect(declares("export type ConfigLike = 1;", "Config")).toBe(false); + expect( + reExportBlocks( + 'export { a, type B, c as d } from "../../src/x.ts";\nexport type { E } from "../../src/y.ts";' + ) + ).toEqual([ + { names: ["a", "B", "c"], target: "x.ts" }, + { names: ["E"], target: "y.ts" }, + ]); + }); + + it("test/support/src.ts re-exports each name from the module that declares it", () => { + // Re-exporting through `src/index.ts` would tie every test to the barrel, + // which carries only the host's contract, and that coupling is what this + // file exists to remove; and a name taken from a module that only passes + // it on breaks when P10a moves the module that really defines it. + const source = readFileSync(SUPPORT, "utf8"); + expect(source).not.toMatch(/src\/index\.ts/u); + const blocks = reExportBlocks(source); + // Every `from "../../src/…"` in the file is one of the parsed blocks, and + // there is one block per module, so the walk below is not vacuous. + expect(blocks.length).toBe( + source.match(/from "\.\.\/\.\.\/src\//gu)?.length ?? 0 + ); + expect(blocks.length).toBeGreaterThanOrEqual(40); + const missing = blocks + .map(({ target }) => target) + .filter((target) => !existsSync(join(SRC_DIR, target))); + expect(missing).toEqual([]); + const notDeclared = blocks.flatMap(({ names, target }) => { + const defining = readFileSync(join(SRC_DIR, target), "utf8"); + return names + .filter((name) => !declares(defining, name)) + .map((name) => `${name} is not declared in src/${target}`); + }); + expect(notDeclared).toEqual([]); + }); +}); + +describe("architecture · every test reaches src through one door", () => { + const TEST_DIR = import.meta.dirname; + + /** + * A static `import`/`export … from "(../)+src/…"`, multi-line blocks + * included, and a side-effect `import "(../)+src/…";`. A + * `vi.mock("../src/x.ts")` target and its `importOriginal` + * type are not statements of this shape: module identity is their subject, + * so they keep their paths. + */ + const STATIC_SRC_IMPORT = + /^(?:(?:import|export)\b[^;]*?\bfrom\s+|import\s+)"(?:\.\.\/)+src\/[^"]+"/gmu; + + /** + * A dynamic `import("(../)+src/…")` that is not a `typeof import(…)` type. + * It is allowed only as a fresh re-import, so only in a file that calls + * `vi.resetModules(`; anywhere else it is the door bypassed at runtime. + */ + const DYNAMIC_SRC_IMPORT = + /(? + /@(?:vitest|jest)-environment\s+([\w-]+)\b/u.exec(source)?.[1] === "jsdom"; + + /** + * Files whose subject is the `src` layout or the barrel, the door itself, + * and the setup file that imports `src` directly — a setup file that loaded + * the door would pull the host graph into every jsdom test. Every other + * `support/` file is walked like a test. + */ + const exempt = (file: string): boolean => + file === "support/src.ts" || + file === "support/reset-process-state.ts" || + file === "architecture.test.ts" || + file === "public-api.test.ts"; + + /** A value import of the door: a client test must not load the host graph. */ + const DOOR_VALUE_IMPORT = + /^import\s+(?!type\b)[^;]*?\bfrom\s+"(?:\.\.\/)*(?:\.\/)?support\/src\.ts"/gmu; + + /** + * Comments blanked out with their line breaks kept, so `file:line` is true. + * Line by line, so a `/*` inside a string cannot open a fake block that + * hides the code after it (see `support/strip-comments.ts`). + */ + const blankComments = blankCommentLines; + + /** The test tree the gate walks: every `.ts`/`.tsx` not exempt by name. */ + const walked = (): string[] => filesUnder(TEST_DIR).filter((f) => !exempt(f)); + + it("knows a static import from a mock target or a fresh re-import", () => { + const matches = (line: string): boolean => + new RegExp(STATIC_SRC_IMPORT.source, "mu").test(line); + expect(matches('import { a } from "../src/a.ts";')).toBe(true); + expect(matches('import type { A } from "../../src/a.ts";')).toBe(true); + expect(matches('import {\n a,\n b,\n} from "../src/a.ts";')).toBe(true); + expect( + matches('vi.mock("../src/a.ts", async (importOriginal) => ({}));') + ).toBe(false); + expect(matches('importOriginal()')).toBe( + false + ); + expect(matches('const m = await import("../src/a.ts");')).toBe(false); + // A side-effect import loads the module just the same. + expect(matches('import "../src/a.ts";')).toBe(true); + const dynamic = (text: string): boolean => + new RegExp(DYNAMIC_SRC_IMPORT.source, "u").test(text); + expect(dynamic('const m = await import("../src/a.ts");')).toBe(true); + expect(dynamic('importOriginal()')).toBe( + false + ); + // The pragma as vitest reads it, built so this file stays a node test. + const pragma = ["@vitest", "environment jsdom"].join("-"); + expect(isClientTest(`// ${pragma}\n`)).toBe(true); + expect(isClientTest(`/** ${pragma} */\n`)).toBe(true); + expect(isClientTest("// a node test\n")).toBe(false); + expect(isClientTest(readFileSync(import.meta.filename, "utf8"))).toBe( + false + ); + // The first pragma decides, as in vitest, and its name is read whole. + const nodePragma = ["@vitest", "environment node"].join("-"); + expect( + isClientTest(`// ${nodePragma}\n// unlike the ${pragma} tests\n`) + ).toBe(false); + expect( + isClientTest(`// ${pragma}\n// unlike the ${nodePragma} tests\n`) + ).toBe(true); + expect(isClientTest(`// ${pragma}-global\n`)).toBe(false); + // A `/*` inside a string opens no comment: the import after it is seen. + const tricky = 'x("a/*b");\nimport "../src/x.ts";\n// c */\n'; + expect( + new RegExp(STATIC_SRC_IMPORT.source, "mu").test(blankComments(tricky)) + ).toBe(true); + const trickyDynamic = + 'x("a/*b");\nconst m = await import("../src/x.ts");\n// c */\n'; + expect( + new RegExp(DYNAMIC_SRC_IMPORT.source, "u").test( + blankComments(trickyDynamic) + ) + ).toBe(true); + const door = (text: string): boolean => + new RegExp(DOOR_VALUE_IMPORT.source, "mu").test(text); + expect(door('import { a } from "./support/src.ts";')).toBe(true); + expect(door('import { a } from "../support/src.ts";')).toBe(true); + expect(door('import type { A } from "./support/src.ts";')).toBe(false); + // The walk is real: it reaches the suites, the mocks and the e2e tree, + // and leaves the door itself out. + const files = walked(); + expect(files).toContain("catalog.test.ts"); + expect(files).toContain("mock/helpers.ts"); + expect(files).toContain("e2e/protocol-routing.e2e.ts"); + expect(files).not.toContain("support/src.ts"); + expect(files).toContain("support/catalogs.ts"); + }); + + it("no static src import outside the exemptions", () => { + // A test that names a `src` path breaks on every module move; the door, + // test/support/src.ts, is the one file that knows where names live. + const offenders = walked().flatMap((file) => { + const raw = readFileSync(join(TEST_DIR, file), "utf8"); + const source = blankComments(raw); + const at = (match: RegExpExecArray, why: string): string => { + const end = (match.index ?? 0) + match[0].length; + const line = source.slice(0, end).split("\n").length; + return `${file}:${String(line)} ${why}`; + }; + if (isClientTest(raw)) { + // A client test may name its own `src` module; what it must not do + // is load the door, which reaches the whole host graph. + return [...source.matchAll(DOOR_VALUE_IMPORT)].map((match) => + at(match, "jsdom test loads the door") + ); + } + const dynamic = source.includes("vi.resetModules(") + ? [] + : [...source.matchAll(DYNAMIC_SRC_IMPORT)].map((match) => + at(match, "dynamic src import without vi.resetModules()") + ); + return [ + ...[...source.matchAll(STATIC_SRC_IMPORT)].map((match) => + at(match, "static src import") + ), + ...dynamic, + ]; + }); + expect(offenders).toEqual([]); + }); }); diff --git a/test/attachment-services.test.ts b/test/attachment-services.test.ts index fa8ecf05..21c6eaca 100644 --- a/test/attachment-services.test.ts +++ b/test/attachment-services.test.ts @@ -3,10 +3,12 @@ import { createRequire } from "node:module"; import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; import { expect, it, vi } from "vitest"; -import { attachmentResolvers } from "../src/attachment-services.ts"; -import { OPENCODE_GO_CATALOG } from "../src/catalog-data.ts"; -import { registerGoogleBridge } from "../src/google-bridge.ts"; import { testPi } from "./google-runtime-fixture.ts"; +import { + OPENCODE_GO_CATALOG, + attachmentResolvers, + registerGoogleBridge, +} from "./support/src.ts"; const scopedServices = (services: Record) => { const cleanups: (() => void)[] = []; diff --git a/test/auth-api-key.test.ts b/test/auth-api-key.test.ts index 73e4ad06..05b2db5a 100644 --- a/test/auth-api-key.test.ts +++ b/test/auth-api-key.test.ts @@ -21,7 +21,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { GatewayErrorPayload } from "./mock/protocols/index.ts"; import { GatewayTestRunner, @@ -30,6 +29,7 @@ import { MOCK_TEXT, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/catalog-family-api.test.ts b/test/catalog-family-api.test.ts index be19d171..ae9e714d 100644 --- a/test/catalog-family-api.test.ts +++ b/test/catalog-family-api.test.ts @@ -17,7 +17,7 @@ import { describe, expect, it } from "vitest"; -import { parseModelsDevCatalog } from "../src/models-catalog.ts"; +import { parseModelsDevCatalog } from "./support/src.ts"; describe("the family map", () => { it("reads the same whatever order the vendor lists its models in", () => { diff --git a/test/catalog.test.ts b/test/catalog.test.ts index 1a7d7c45..0028ed3d 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -11,15 +11,19 @@ import { readFile } from "node:fs/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { OPENCODE_FAMILY_API } from "../src/catalog-data.ts"; import { + type ActiveTurnState, + OPENCODE_FAMILY_API, OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, PROTOCOL_FOR_SDK, ROUTE_FOR_PROTOCOL, SESSION_HEADER, + UNSERVED_PLANE_SDKS, UNSERVED_SDKS, enrichModelsResponse, + findModelSpec, + GO_MODEL_LIMITS, getLiveGoCatalog, getLiveZenCatalog, internalRouteFor, @@ -30,13 +34,10 @@ import { parseModelsDevCatalog, patchFetch, refreshCatalog, + resetCatalogState, resolveConfig, - resolveRoutedKey, sanitizeModalities, - type ActiveTurnState, -} from "../src/index.ts"; -import { findModelSpec } from "../src/models-catalog.ts"; -import { UNSERVED_PLANE_SDKS } from "../src/responses-routes.ts"; +} from "./support/src.ts"; import { createCaptureFetch, headerOf, SESSION_RE } from "./test-helpers.ts"; afterEach(() => { @@ -197,12 +198,12 @@ describe("OpenCode Model Catalog & Enrichment", () => { // replacing the whole set — so these counts are the cold-start picker, and a // silent drop here is a model nobody can pick. Regenerate with // `scripts/regenerate-catalog-shim.ts` rather than editing by hand. - expect(OPENCODE_ZEN_CATALOG.length).toBe(80); + expect(OPENCODE_ZEN_CATALOG.length).toBe(81); const freeModels = OPENCODE_ZEN_CATALOG.filter((m) => m.is_free === true); // Only the free tiers the registry still lists as active. The count is the // `is_free` flag the shim carries, NOT the `-free` suffix: `big-pickle` is // flagged and is not suffixed, so a suffix rule would have missed it. - expect(freeModels.length).toBe(8); + expect(freeModels.length).toBe(9); const ids = new Set(OPENCODE_ZEN_CATALOG.map((m) => m.id)); expect(ids.has("beam-free")).toBe(true); @@ -397,15 +398,22 @@ describe("OpenCode Model Catalog & Enrichment", () => { // The one status the catalog answers for is 404, because the Go plane's // modern base serves no `/models` route at all. for (const status of [401, 403, 429, 500, 502]) { + // The rate limit carries the header a caller backs off by; the + // passthrough must hand it on with the status. + const headers: Record = + status === 429 ? { "retry-after": "30" } : {}; const upstream = Response.json( { error: { message: "refused" } }, - { status } + { headers, status } ); const enriched = await enrichModelsResponse( "https://opencode.ai/inference/openai/v1/models", upstream ); expect(enriched.status, `status ${status}`).toBe(status); + expect(enriched.headers.get("retry-after"), `status ${status}`).toBe( + headers["retry-after"] ?? null + ); const body = (await enriched.json()) as { data?: unknown[] }; expect(body.data, `status ${status} must not carry a catalog`).toBe( undefined @@ -422,6 +430,28 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(filled.data.length).toBeGreaterThan(0); }); + it("fills a Zen-plane 404 with the Zen catalog, as it does the Go plane's", async () => { + // The 404 rule is not a Go special case: a Zen base that serves no + // listing is answered from the Zen catalog the same way. + const out = await enrichModelsResponse( + "https://opencode.ai/zen/v1/models", + new Response("Not Found", { status: 404 }) + ); + expect(out.status).toBe(200); + const body = (await out.json()) as { data: unknown[] }; + expect(body.data.length).toBe(getLiveZenCatalog().length); + }); + + it("still answers a 200 whose body is not JSON with the full catalog", async () => { + const out = await enrichModelsResponse( + "https://opencode.ai/zen/v1/models", + new Response("not json", { status: 200 }) + ); + expect(out.status).toBe(200); + const body = (await out.json()) as { data: unknown[] }; + expect(body.data.length).toBe(getLiveZenCatalog().length); + }); + it("answers the modern Go listing with the Go catalog, not the Zen one", async () => { // **This is the bug the Go plane's endpoint change exposed.** The Go plane's // base is the `inference` family, and the adapter lists its models at @@ -790,6 +820,29 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(seen[0]).not.toContain("models.dev"); }); + it("resetCatalogState puts a refreshed catalog back to the bundled shim", async () => { + // The per-test reset in `test/support/` relies on this, and a reset that + // restored nothing would let every catalog count depend on run order again. + await refreshCatalog( + (async () => + Response.json({ + "opencode-go": { + models: { "reset-probe-model": { name: "Reset Probe" } }, + }, + })) as unknown as typeof fetch, + true + ); + expect(getLiveGoCatalog().some((m) => m.id === "reset-probe-model")).toBe( + true + ); + resetCatalogState(); + expect(getLiveGoCatalog().some((m) => m.id === "reset-probe-model")).toBe( + false + ); + expect(getLiveGoCatalog()).toHaveLength(OPENCODE_GO_CATALOG.length); + expect(getLiveZenCatalog()).toHaveLength(OPENCODE_ZEN_CATALOG.length); + }); + it("refreshCatalog degrades gracefully on network errors without throwing", async () => { const before = { go: getLiveGoCatalog().length, @@ -813,47 +866,6 @@ describe("OpenCode Model Catalog & Enrichment", () => { ); }); - it("resolveRoutedKey identifies routed provider tier and key prefix", async () => { - const mockCtx = { - loader: { - entries: () => [ - { - options: { - config: { - providers: { - "opencode-go": { - apiKeyEnv: "OPENCODE_GO_API_KEY", - }, - opencode: { - apiKeyEnv: "OPENCODE_API_KEY", - }, - }, - }, - id: "llm-pi-ai", - name: "@deepseek-ai/dsh-llm-pi-ai", - }, - }, - ], - }, - }; - - process.env.OPENCODE_GO_API_KEY = "sk-68klEy0x2_test_go_key"; - process.env.OPENCODE_API_KEY = "oc_sk_ac6304e0f930_test_zen_key"; - - try { - const goDetails = await resolveRoutedKey(mockCtx, "opencode-go"); - expect(goDetails.tier).toBe("go"); - expect(goDetails.keyPrefix).toBe("sk-68klEy0"); - - const zenDetails = await resolveRoutedKey(mockCtx, "opencode"); - expect(zenDetails.tier).toBe("zen"); - expect(zenDetails.keyPrefix).toBe("oc_sk_ac63"); - } finally { - delete process.env.OPENCODE_GO_API_KEY; - delete process.env.OPENCODE_API_KEY; - } - }); - it("sanitizeModalities restricts modalities strictly to text and image", () => { expect( sanitizeModalities(["text", "image", "video", "audio", "pdf"]) @@ -1058,6 +1070,43 @@ describe("catalog · the README's routing table", () => { expect(served).toEqual([zen, go]); }); + it("holds every allowance figure in the docs to the generated table", async () => { + // The counts above are parsed, the dollar figures beside a model id were + // not, and the meter's mock-ups paired one model with another's allowance + // in four files while the README's own prose had the right one. Every + // ` Go $a · Plus $b` a document prints is read back against the table + // the meter itself prints from. + let seen = 0; + for (const file of [ + "../README.md", + "../README.zh-CN.md", + "../docs/quota-meter.md", + "../docs/quota-meter.zh-CN.md", + ]) { + const text = await readFile(new URL(file, import.meta.url), "utf8"); + const figures = [ + ...text.matchAll( + /([a-z0-9][a-z0-9.-]*)[\s·,`]+Go \$([\d.]+) · Plus \$([\d.]+)/gu + ), + ]; + expect( + figures.length, + `${file} prints no allowance figure to check` + ).toBeGreaterThan(0); + for (const [, id = "", go, plus] of figures) { + const row = GO_MODEL_LIMITS[id]; + expect(row, `${file} prints an allowance for ${id}`).toBeDefined(); + expect( + [Number(go), Number(plus)], + `${file}: the allowance it prints for ${id}` + ).toEqual([row?.go, row?.goPlus]); + seen += 1; + } + } + // A known answer, so a regex that stops matching cannot pass as clean. + expect(seen).toBeGreaterThanOrEqual(8); + }); + it("holds the plane-by-plane table to both catalogs", async () => { // The third document with counts, and the one whose table is per plane: // every cell is a count, and every cell was stale when this was written. diff --git a/test/client-channels.test.ts b/test/client-channels.test.ts index ed8879ff..c0a712e8 100644 --- a/test/client-channels.test.ts +++ b/test/client-channels.test.ts @@ -15,8 +15,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { opencodeDatabaseFile, opencodeRoots } from "../src/opencode-paths.ts"; -import { readOpencodeCredentials } from "../src/opencode-store.ts"; +import { + opencodeDatabaseFile, + opencodeRoots, + readOpencodeCredentials, +} from "./support/src.ts"; describe("OpenCode Client Channels & Database File Resolution", () => { it.each([ diff --git a/test/client-headers-affinity.test.ts b/test/client-headers-affinity.test.ts index 65fe0289..34b9164a 100644 --- a/test/client-headers-affinity.test.ts +++ b/test/client-headers-affinity.test.ts @@ -24,19 +24,19 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; -import { - PARENT_SESSION_ALT_HEADER, - PARENT_SESSION_HEADER, - SESSION_AFFINITY_HEADER, - SESSION_HEADER, -} from "../src/session.ts"; import { ClientSimulator, GatewayTestRunner, MOCK_SESSION, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { + PARENT_SESSION_ALT_HEADER, + PARENT_SESSION_HEADER, + SESSION_AFFINITY_HEADER, + SESSION_HEADER, + clearCapturedApiKeys, +} from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/composition.test.ts b/test/composition.test.ts index 1c6d09af..9d89df1e 100644 --- a/test/composition.test.ts +++ b/test/composition.test.ts @@ -26,8 +26,7 @@ import { Context } from "@deepseek-ai/cordis"; import { describe, expect, it } from "vitest"; -import { apply, inject } from "../src/index.ts"; -import type { CordisContext } from "../src/index.ts"; +import { type CordisContext, apply, inject } from "./support/src.ts"; /** The one service the plugin declares, shaped as the plugin reads it. */ const llmService = () => ({ diff --git a/test/config-values.test.ts b/test/config-values.test.ts index e6661443..0608de67 100644 --- a/test/config-values.test.ts +++ b/test/config-values.test.ts @@ -16,7 +16,7 @@ import { readString, readStringList, unwrapNode, -} from "../src/config-values.ts"; +} from "./support/src.ts"; /** A value that is genuinely absent, without writing the `undefined` literal. */ const ABSENT: unknown = undefined; diff --git a/test/config.test.ts b/test/config.test.ts index 6b2b1979..4a9335f3 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -8,19 +8,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { CONFIG_DEFAULTS } from "../src/config.ts"; import { + type ActiveTurnState, + CONFIG_DEFAULTS, Config, DEFAULT_ENDPOINT_FAMILY, DEFAULT_KEY_SOURCE, ENDPOINT_FAMILIES, - isOpenCodeRequest, KEY_SOURCE_POLICIES, - resolveConfig, - type ActiveTurnState, type ModelOverrideConfig, type PluginConfig, -} from "../src/index.ts"; + isOpenCodeRequest, + resolveConfig, +} from "./support/src.ts"; afterEach(() => { vi.unstubAllGlobals(); @@ -282,6 +282,20 @@ describe("resolveConfig", () => { }); describe("Config schema", () => { + it("accepts a stale row that still carries internalRoutes, and ignores the key", () => { + // Removed in 1.x: the per-protocol routes always mount. An upgrading + // deployment's row may still carry the key, and the host validates the + // row through the schema's standard validator before the plugin loads, + // so a throw or an issue here would stop the plugin. The schema's own + // absence is pinned in deleted-settings.test.ts. + const stale = { internalRoutes: false } as unknown as PluginConfig; + expect(() => Config(stale)).not.toThrow(); + const result = Config["~standard"].validate(stale); + expect(result).not.toBeInstanceOf(Promise); + expect((result as { issues?: unknown }).issues).toBeUndefined(); + expect(resolveConfig(stale)).not.toHaveProperty("internalRoutes"); + }); + it("validates an empty row to the documented defaults", () => { // The harness validates the patch row against this schema before the // plugin sees it, and the settings UI serves the namespace from it — so @@ -301,7 +315,6 @@ describe("Config schema", () => { "injectOriginHeaders", "injectProject", "injectUserAgent", - "internalRoutes", "keySource", "maxContextWindow", "modelOverrides", @@ -363,8 +376,8 @@ describe("Config schema", () => { it("accepts each endpoint family and degrades an unknown one to auto", () => { // Same rule as the key-source policy, for the same reason: the family is - // user-facing and hand-editable, and `auto` follows the catalog, so a typo - // degrades to the answer that cannot be wrong about which family is current. + // user-facing and hand-editable, so a typo degrades to `auto`, the default, + // rather than selecting a family the user never named. for (const family of ENDPOINT_FAMILIES) { expect(resolveConfig({ endpoint: family }).endpoint).toBe(family); } diff --git a/test/cordis-context.test.ts b/test/cordis-context.test.ts index f0887d5e..5579c6b4 100644 --- a/test/cordis-context.test.ts +++ b/test/cordis-context.test.ts @@ -17,7 +17,7 @@ import { readCredentialsResolver, readEntryOptions, readSessionMetaResolver, -} from "../src/cordis-context.ts"; +} from "./support/src.ts"; describe("cordis-context: readEntryOptions", () => { it("reads id and name from fiber.entry.options", () => { diff --git a/test/debug.test.ts b/test/debug.test.ts index 2bce527a..621806de 100644 --- a/test/debug.test.ts +++ b/test/debug.test.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { recordDebug } from "../src/debug.ts"; +import { recordDebug } from "./support/src.ts"; // The write path catches whatever appendFile throws, and one of its branches // only exists for a rejection that is not an Error — which a real filesystem diff --git a/test/deleted-settings.test.ts b/test/deleted-settings.test.ts new file mode 100644 index 00000000..02a84a71 --- /dev/null +++ b/test/deleted-settings.test.ts @@ -0,0 +1,95 @@ +/** + * Deleted settings are gone everywhere, not just from the schema. + * + * A setting lives in five places: the schema (`Config`), the resolver + * (`resolveConfig`), the settings registry (`CONFIG_ONLY_FIELDS`), the bundle + * manifest's header (`cordis.patch.yml`) and the READMEs. Removing it from one + * and not the others leaves a row the UI offers and the plugin ignores, or a + * documented switch that does nothing. So each removal is pinned in every + * place at once, and the source walk catches a code path still branching on + * the name. + * + * `internalRoutes` was removed in 1.x (D2): the per-protocol routes always + * mount. `resolveRoutedKey` (D4) was exported and never called from `src/`. + * + * @module test/deleted-settings.test + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { Config, CONFIG_ONLY_FIELDS, resolveConfig } from "./support/src.ts"; +import { blankCommentLines } from "./support/strip-comments.ts"; + +const ROOT = join(import.meta.dirname, ".."); + +/** A deleted name, as a whole word. */ +const DELETED = /\b(?:internalRoutes|resolveRoutedKey|RoutedKeyDetails)\b/u; + +/** Every `.ts`/`.tsx` under `dir`, at any depth, relative to `ROOT`. */ +const sourcesUnder = (dir: string): string[] => + readdirSync(join(ROOT, dir), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.tsx?$/u.test(entry.name)) + .map((entry) => + join(entry.parentPath, entry.name) + .slice(ROOT.length + 1) + .replaceAll("\\", "/") + ) + .toSorted(); + +/** + * Comments blanked out with their line breaks kept, so `file:line` is true; + * line by line, so a `/*` in a string cannot hide the code after it. + */ +const blankComments = blankCommentLines; + +const read = (file: string): string => readFileSync(join(ROOT, file), "utf8"); + +/** `text` without the `## ` section that starts at `heading`. */ +const withoutSection = (text: string, heading: string): string => { + const start = text.indexOf(`${heading}\n`); + if (start === -1) { + return text; + } + const next = text.indexOf("\n## ", start + heading.length); + return text.slice(0, start) + (next === -1 ? "" : text.slice(next + 1)); +}; + +describe("deleted settings", () => { + it("internalRoutes is gone from the schema, the resolver, the registry and the manifest", () => { + const schemaKeys = Object.keys(Config.dict ?? {}); + // The dict read is real: a live key is there. + expect(schemaKeys).toContain("endpoint"); + expect(schemaKeys).not.toContain("internalRoutes"); + expect(Object.keys(resolveConfig({}))).not.toContain("internalRoutes"); + expect(CONFIG_ONLY_FIELDS.map((f) => f.field)).not.toContain( + "internalRoutes" + ); + expect(read("cordis.patch.yml")).not.toMatch(/\binternalRoutes\b/u); + }); + + it("no source, door or README still names internalRoutes or resolveRoutedKey", () => { + const files = [...sourcesUnder("src"), "test/support/src.ts"]; + // Non-vacuous: the walk reaches nested modules. + expect(files).toContain("src/config.ts"); + expect(files).toContain("src/utils/guards.ts"); + const offenders = files.flatMap((file) => + blankComments(read(file)) + .split("\n") + .flatMap((line, index) => + DELETED.test(line) ? [`${file}:${String(index + 1)}`] : [] + ) + ); + expect(offenders).toEqual([]); + // A README may name a removed setting only where it announces the removal. + for (const [file, heading] of [ + ["README.md", "## Changed in 1.x"], + ["README.zh-CN.md", "## 1.x 变更"], + ] as const) { + const text = read(file); + expect(text).toContain(heading); + expect(withoutSection(text, heading)).not.toMatch(/\binternalRoutes\b/u); + } + }); +}); diff --git a/test/e2e/dispatch-levels.e2e.ts b/test/e2e/dispatch-levels.e2e.ts index 6f1bb651..05aca453 100644 --- a/test/e2e/dispatch-levels.e2e.ts +++ b/test/e2e/dispatch-levels.e2e.ts @@ -29,12 +29,12 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/models"; import { describe, expect, it } from "vitest"; import { - decorateModelInfo, - decoratePlaneAdapters, OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, + decorateModelInfo, + decoratePlaneAdapters, resolveConfig, -} from "../../src/index.ts"; +} from "../support/src.ts"; const ENABLED = process.env.OPENCODE_E2E === "1"; diff --git a/test/e2e/opencode-live.e2e.ts b/test/e2e/opencode-live.e2e.ts index 67514c61..cdd55d27 100644 --- a/test/e2e/opencode-live.e2e.ts +++ b/test/e2e/opencode-live.e2e.ts @@ -20,13 +20,13 @@ import { describe, expect, it } from "vitest"; import { - enrichModelsResponse, + type GoUsage, OPENCODE_ZEN_CATALOG, + SESSION_HEADER, + enrichModelsResponse, openCodeSessionIdFor, parseGoUsage, - SESSION_HEADER, - type GoUsage, -} from "../../src/index.ts"; +} from "../support/src.ts"; const LIVE = process.env.OPENCODE_E2E === "1"; diff --git a/test/e2e/patched-fetch-headers.e2e.ts b/test/e2e/patched-fetch-headers.e2e.ts index 4d1ff5db..b3fa85fb 100644 --- a/test/e2e/patched-fetch-headers.e2e.ts +++ b/test/e2e/patched-fetch-headers.e2e.ts @@ -19,15 +19,15 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { type ActiveTurnState, - openCodeSessionIdFor, OPENCODE_UA, PARENT_SESSION_ALT_HEADER, PARENT_SESSION_HEADER, - patchFetch, - resolveConfig, SESSION_AFFINITY_HEADER, SESSION_HEADER, -} from "../../src/index.ts"; + openCodeSessionIdFor, + patchFetch, + resolveConfig, +} from "../support/src.ts"; const ENABLED = process.env.OPENCODE_E2E === "1"; diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index 9d5b519f..93f4fc2b 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -63,11 +63,12 @@ import { findModelSpecOn, internalRouteFor, isServableSdk, -} from "../../src/index.ts"; +} from "../support/src.ts"; import { accessDisabled, freeTierClosed, gatewayBusy, + retiredAtTheGateway, usableAsSubject, } from "./verdict.ts"; @@ -312,14 +313,19 @@ describe.skipIf(!LIVE)("live protocol routing", () => { candidates: readonly string[], protocol: string, key?: string, - base: string = ZEN_BASE + base: string = ZEN_BASE, + // A case that asserts one particular answer narrows the pick to candidates + // that give it, so an otherwise observable answer of another kind (an + // unmetered 200 where the case asserts the free-tier gate) moves the walk on + // instead of being asserted on. + accept: (answer: Probe) => boolean = () => true ): Promise => { for (const model of candidates) { const answer = await probe(protocol, model, key, base); // The rule is in `verdict.ts`, where it is unit-tested: a refusal and a // retirement are both vendor-side facts, and a case built on either fails - // for a reason that is not its own. - if (usableAsSubject(answer)) { + // for a reason that is not its own. The case's own narrowing comes after. + if (usableAsSubject(answer) && accept(answer)) { return model; } } @@ -332,11 +338,19 @@ describe.skipIf(!LIVE)("live protocol routing", () => { // The vendor catalog no longer lists a free model on /responses, so the // free-tier routing case now asserts the COMPLETIONS side of the split. // The subject is picked live from the free slice, in catalog order, so - // whichever free model is alive today is the one tested. + // whichever GATED free model is alive today is the one tested: the case + // asserts the free-tier gate, so an unmetered model (which answers 200 + // everywhere) cannot be its subject. const candidates = OPENCODE_ZEN_CATALOG.filter((m) => m.is_free).map( (m) => m.id ); - const model = await pickModel(candidates, "openai-completions"); + const model = await pickModel( + candidates, + "openai-completions", + undefined, + ZEN_BASE, + freeTierClosed + ); if (model === undefined) { context.skip(); return; @@ -812,28 +826,36 @@ describe.skipIf(!LIVE)("live protocol routing", () => { servedProtocolFor(model) ?? "openai-completions", model ); - if (freeTierClosed(response)) { - continue; - } if (gatewayBusy(response)) { // The gateway answered, but about its own load. That is neither a // completion nor a dead tier, and it must not be reported as one. + // Asked before the refusals below, which count a 429 among them. busy = true; continue; } + // A gated, withdrawn or refused candidate says nothing about whether + // the free tier is reachable, so the walk moves on to the next one. + if ( + freeTierClosed(response) || + retiredAtTheGateway(response) || + accessDisabled(response) + ) { + continue; + } answered = { model, status: response.status }; break; } if (answered === undefined) { if (busy) { - // Every candidate was busy, which says nothing about the tier. + // No candidate answered and at least one was busy, which says + // nothing about the tier. context.skip(); return; } // Reported rather than skipped: the account (or the vendor) closed the // whole free tier, and that is worth a line in the run's output. console.log( - `[e2e] every free candidate answered FreeTierError — the free tier needs a session token this plugin does not hold; see docs/opencode-1.19-api.md. The header-restoration case did not run.` + `[e2e] no free candidate answered with a completion — each was gated (FreeTierError: the free tier needs a session token this plugin does not hold; see docs/opencode-1.19-api.md), withdrawn upstream, refused for the account (no funds, no entitlement), or never answered. The header-restoration case did not run.` ); return; } diff --git a/test/e2e/refusal-classification.e2e.ts b/test/e2e/refusal-classification.e2e.ts index a66df90f..3bd16d37 100644 --- a/test/e2e/refusal-classification.e2e.ts +++ b/test/e2e/refusal-classification.e2e.ts @@ -36,7 +36,7 @@ import { type ActiveTurnState, patchFetch, resolveConfig, -} from "../../src/index.ts"; +} from "../support/src.ts"; const ENABLED = process.env.OPENCODE_E2E === "1"; diff --git a/test/e2e/verdict.ts b/test/e2e/verdict.ts index a3ac75a3..68b7087f 100644 --- a/test/e2e/verdict.ts +++ b/test/e2e/verdict.ts @@ -74,6 +74,12 @@ export const accessDisabled = (result: Probe): boolean => * slice while the gateway answers `410 ModelDeprecated`. Measured: the registry * listed `gemini-3-flash` as active while the gateway refused it. * + * A third shape says the same thing about an upstream rather than the + * gateway's own entry: `400 Upstream request failed: Model is unavailable`. + * Measured on a free model the catalog still listed as active, answered + * identically on two runs a minute apart. The status alone is not enough — a + * `400` is also how a malformed request is refused — so the body has to say it. + * * Such a model is not a subject to assert on — the case would fail on a * retirement the plugin had nothing to do with. * @@ -81,7 +87,9 @@ export const accessDisabled = (result: Probe): boolean => * @returns whether the answer is a retirement. */ export const retiredAtTheGateway = (result: Probe): boolean => - result.status === 410 || result.summary.includes("ModelDeprecated"); + result.status === 410 || + result.summary.includes("ModelDeprecated") || + (result.status === 400 && result.summary.includes("Model is unavailable")); /** The gateway is busy: it answered, but not about the route. */ export const gatewayBusy = (result: Probe): boolean => result.status === 429; diff --git a/test/endpoint-completions.test.ts b/test/endpoint-completions.test.ts index c907ceaf..a1e28ed6 100644 --- a/test/endpoint-completions.test.ts +++ b/test/endpoint-completions.test.ts @@ -26,7 +26,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { GatewayErrorPayload, V1CompletionsUnaryPayload, @@ -36,6 +35,7 @@ import { MOCK_SESSION, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/endpoint-copy.test.ts b/test/endpoint-copy.test.ts new file mode 100644 index 00000000..79cc797f --- /dev/null +++ b/test/endpoint-copy.test.ts @@ -0,0 +1,71 @@ +/** + * What the endpoint knob says it does, on every surface that says it. + * + * The schema description, the card's two locales and both READMEs described + * `modern` as `/inference/v1` — a base the code never sends. `modern` (and + * `auto`, which is the same table today) sends each protocol to its own + * inference path: `/inference/{openai,anthropic,google}/…`, under + * `/inference/go/…` on the Go plane. `legacy` is the older single base per + * plane. Copy is the only place a user learns that, so it is pinned. + * + * @module test/endpoint-copy.test + */ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { Config, en, zh } from "./support/src.ts"; + +/** The first line of a file that contains `marker`, or fail loudly. */ +const lineOf = async (file: string, marker: string): Promise => { + const text = await readFile(new URL(file, import.meta.url), "utf8"); + const line = text.split("\n").find((l) => l.includes(marker)); + if (line === undefined) { + throw new Error(`${file} has no line containing ${marker}`); + } + return line; +}; + +describe("endpoint copy", () => { + it("the endpoint copy names the per-protocol inference family and calls auto an alias of modern", async () => { + const schema = ( + Config as unknown as { + dict: { endpoint: { meta: { description: unknown } } }; + } + ).dict.endpoint.meta.description; + expect(typeof schema).toBe("string"); + const d = String(schema); + expect(d).not.toContain("inference/v1"); + expect(d).toContain("inference/{openai,anthropic,google}"); + expect(d).toContain("inference/go/{openai,anthropic,google}"); + expect(d).toMatch(/auto: an alias of modern/u); + + for (const copy of [en, zh]) { + const card = copy.endpointModern + copy.endpointAuto + copy.endpointHint; + expect(card).not.toContain("inference/v1"); + expect(card).toContain("inference/openai"); + // Legacy is one base per plane, and the Go plane's is not zen/v1. + expect(copy.endpointHint).toContain("zen/go/v1"); + } + expect(en.endpointAuto).toContain("same as Modern"); + expect(zh.endpointAuto).toContain("同新端点"); + + const row = await lineOf("../README.md", "API Endpoint"); + expect(row).not.toContain("inference/v1"); + expect(row).toContain("inference/{openai,anthropic,google}"); + expect(row).toContain("same as Modern"); + const zhRow = await lineOf("../README.zh-CN.md", "API 端点"); + expect(zhRow).not.toContain("inference/v1"); + expect(zhRow).toContain("inference/{openai,anthropic,google}"); + expect(zhRow).toContain("同新端点"); + + for (const [file, marker] of [ + ["../README.md", "**Which endpoint family.**"], + ["../README.zh-CN.md", "**发往哪个端点家族。**"], + ] as const) { + const paragraph = await lineOf(file, marker); + expect(paragraph, file).not.toContain("inference/v1"); + expect(paragraph, file).toContain("/inference/openai/v1"); + } + }); +}); diff --git a/test/endpoint-google.test.ts b/test/endpoint-google.test.ts index 957d49df..d1c7f0eb 100644 --- a/test/endpoint-google.test.ts +++ b/test/endpoint-google.test.ts @@ -25,7 +25,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { GatewayErrorPayload, GoogleUnaryPayload, @@ -35,6 +34,7 @@ import { MOCK_SESSION, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/endpoint-messages.test.ts b/test/endpoint-messages.test.ts index 22561262..345d05fa 100644 --- a/test/endpoint-messages.test.ts +++ b/test/endpoint-messages.test.ts @@ -24,7 +24,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { GatewayErrorPayload, MessagesUnaryPayload, @@ -34,6 +33,7 @@ import { MOCK_SESSION, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/endpoint-models-catalog.test.ts b/test/endpoint-models-catalog.test.ts index 78226714..addf2978 100644 --- a/test/endpoint-models-catalog.test.ts +++ b/test/endpoint-models-catalog.test.ts @@ -22,7 +22,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { CatalogListingPayload, GatewayErrorPayload, @@ -32,6 +31,7 @@ import { MOCK_SESSION, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/endpoint-responses.test.ts b/test/endpoint-responses.test.ts index 5d7acafb..31219a28 100644 --- a/test/endpoint-responses.test.ts +++ b/test/endpoint-responses.test.ts @@ -29,7 +29,6 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { V2ResponsesUnaryPayload } from "./mock/protocols/index.ts"; import { GatewayTestRunner, @@ -37,6 +36,7 @@ import { MOCK_TEXT, ZEN_KEY, } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/endpoint-usage-quota.test.ts b/test/endpoint-usage-quota.test.ts index c81c2f29..cc3cae0e 100644 --- a/test/endpoint-usage-quota.test.ts +++ b/test/endpoint-usage-quota.test.ts @@ -21,9 +21,9 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { UsageQuotaPayload } from "./mock/protocols/index.ts"; import { GatewayTestRunner, GO_KEY } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/fetch-patch.test.ts b/test/fetch-patch.test.ts index 73c5ddfd..66e697c2 100644 --- a/test/fetch-patch.test.ts +++ b/test/fetch-patch.test.ts @@ -10,6 +10,12 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + GO_FREE_PROBE, + seedDivergentCatalog, + ZEN_FREE_PROBE, +} from "./support/catalogs.ts"; +import { createOpencodeStore } from "./support/opencode-store-fixture.ts"; import { DUMMY_BASH_TOOL, DUMMY_READ_TOOL, @@ -18,15 +24,15 @@ import { SESSION_HEADER, clearCapturedApiKeys, getCapturedApiKey, + getLiveGoCatalog, recordCapturedApiKey, openCodeSessionIdFor, patchFetch, resolveConfig, type ActiveTurnState, -} from "../src/index.ts"; +} from "./support/src.ts"; import { createCaptureFetch, - createOpencodeStore, headerOf, parseJsonBody, toolNamesOf, @@ -91,6 +97,9 @@ describe("patchFetch", () => { "a key captured from a live request (policy auto): none" ); expect(line).toContain("not enabled"); + // The advice names a path this build has: there is no sign-in row here. + expect(line).not.toContain("sign in"); + expect(line).toContain("readOpencodeStore"); }); it("does NOT report a missing key on a plane that offers free models", async () => { @@ -137,6 +146,75 @@ describe("patchFetch", () => { ).toHaveLength(0); }); + it("reads the free model off a Request body, which is how DSH sends a turn", async () => { + // DSH calls fetch(new Request(url, { body })), so `init` carries no body. + // The check used to read `init.body` alone and reported every keyless free + // turn as a missing credential. + const als = new AsyncLocalStorage(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const ok = vi.fn(async () => Response.json({ ok: true })); + const patched = patchFetch( + ok as unknown as typeof fetch, + als, + resolveConfig(), + {} + ); + await patched( + new Request("https://opencode.ai/zen/v1/chat/completions", { + body: JSON.stringify({ messages: [], model: "beam-free" }), + method: "POST", + }) + ); + const freeLines = warn.mock.calls + .map((args) => args.map(String).join(" ")) + .filter((text) => text.includes("no credential resolved")); + expect(freeLines).toHaveLength(0); + + // The same keyless Request for a PAID model is still reported, so the + // case is not passing because the warning went quiet altogether. + await patched( + new Request("https://opencode.ai/zen/v1/chat/completions", { + body: JSON.stringify({ messages: [], model: "gpt-5.4" }), + method: "POST", + }) + ); + expect( + warn.mock.calls + .map((args) => args.map(String).join(" ")) + .some((text) => text.includes("no credential resolved for opencode")) + ).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + it("answers a relay's Go listing with the Go catalog", async () => { + // The listing is claimed against the deployment's own markers. The + // enrichment then re-asked with the DEFAULT markers, so a relay's Go + // listing was filled from the Zen catalog. + const als = new AsyncLocalStorage(); + const upstream = vi.fn(async () => Response.json({ data: [] })); + const patched = patchFetch( + upstream as unknown as typeof fetch, + als, + resolveConfig({ gatewayUrls: ["relay.example/zen"] }), + {} + ); + const response = await patched("https://relay.example/zen/go/v1/models", { + headers: { authorization: "Bearer test-go-key" }, + method: "GET", + }); + const body = (await response.json()) as { data: { id: string }[] }; + const ids = body.data.map((row) => row.id).toSorted(); + expect(ids.length).toBeGreaterThan(0); + expect(ids).toEqual( + getLiveGoCatalog() + .map((spec) => spec.id) + .toSorted() + ); + }); + it("does not name the sources when a credential does resolve", async () => { const als = new AsyncLocalStorage(); const warnings: unknown[][] = []; @@ -732,6 +810,100 @@ describe("patchFetch", () => { expect(names).toEqual([DUMMY_READ_TOOL.name, DUMMY_BASH_TOOL.name]); }); + it("classifies a free model by the plane the request is on", async () => { + // The catalog's free flag is per plane, and the two planes can disagree. + // The seeded probe is free on Zen and paid on Go, under a marker nothing + // matches, so only the plane's own row can admit it: a Zen request gains + // the schemas, the same body on the Go plane is left alone. + await seedDivergentCatalog(); + const als = new AsyncLocalStorage(); + const config = resolveConfig({ freeModelMarker: "none-of-these" }); + const send = async ( + provider: string, + url: string, + model = ZEN_FREE_PROBE + ): Promise => { + const { capture, mockFetch } = createCaptureFetch(); + const value = openCodeSessionIdFor(`plane-${provider}`); + await als.run({ provider, value }, async () => { + await patchFetch( + mockFetch, + als, + config + )(url, { + body: JSON.stringify({ input: "hi", model }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + return toolNamesOf(parseJsonBody(capture.init?.body)); + }; + + expect( + await send("opencode", "https://opencode.ai/zen/v1/responses") + ).toEqual(["read", "bash"]); + expect( + await send("opencode-go", "https://opencode.ai/zen/go/v1/responses") + ).toBeUndefined(); + // The reverse probe, so a Go-first lookup is red in both directions. + expect( + await send( + "opencode-go", + "https://opencode.ai/zen/go/v1/responses", + GO_FREE_PROBE + ) + ).toEqual(["read", "bash"]); + expect( + await send( + "opencode", + "https://opencode.ai/zen/v1/responses", + GO_FREE_PROBE + ) + ).toBeUndefined(); + }); + + it("reads the plane from the URL's path, never from the turn's route", async () => { + // A relay serves its Go plane under a path of the same shape, and the route + // id there is the user's own name, so the path is the one witness both a + // gateway and a relay share. A path that names no Go plane is Zen, whatever + // the turn's provider says. + await seedDivergentCatalog(); + const als = new AsyncLocalStorage(); + const config = resolveConfig({ freeModelMarker: "none-of-these" }); + const send = async (provider: string, url: string): Promise => { + const { capture, mockFetch } = createCaptureFetch(); + const value = openCodeSessionIdFor(`route-${provider}-${url}`); + await als.run({ provider, value }, async () => { + await patchFetch( + mockFetch, + als, + config + )(url, { + body: JSON.stringify({ input: "hi", model: GO_FREE_PROBE }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + }); + return toolNamesOf(parseJsonBody(capture.init?.body)); + }; + + // A relay's Go-shaped path is the Go plane, even on a Zen turn. + expect( + await send("opencode", "https://relay.example/zen/go/v1/responses") + ).toEqual(["read", "bash"]); + // A path naming no Go plane is Zen, even on a Go turn. + expect( + await send("opencode-go", "https://relay.example/v1/responses") + ).toBeUndefined(); + // On the gateway: the URL's plane decides, whatever the turn says. + expect( + await send("opencode", "https://opencode.ai/zen/go/v1/responses") + ).toEqual(["read", "bash"]); + expect( + await send("opencode-go", "https://opencode.ai/zen/v1/responses") + ).toBeUndefined(); + }); + it("skips tool injection for paid models, other paths, and invalid JSON", async () => { const als = new AsyncLocalStorage(); const testSession = openCodeSessionIdFor("skip-cases"); diff --git a/test/free-tier-contract.test.ts b/test/free-tier-contract.test.ts index 804a8176..6b97897d 100644 --- a/test/free-tier-contract.test.ts +++ b/test/free-tier-contract.test.ts @@ -13,9 +13,11 @@ import { describe, expect, it } from "vitest"; -import { OPENCODE_ZEN_CATALOG } from "../src/catalog-data.ts"; -import { findModelSpec } from "../src/models-catalog.ts"; -import { maybeInjectCoreTools } from "../src/tool-fallback.ts"; +import { + findModelSpecOn, + maybeInjectCoreTools, + OPENCODE_ZEN_CATALOG, +} from "./support/src.ts"; const ROUTES = [ "https://opencode.ai/zen/v1/chat/completions", @@ -29,14 +31,17 @@ const freeModelIds = (): string[] => { ids.add(spec.id); } } - // findModelSpec covers ids the live map keys differently (prefixes). + // The live map keys some ids differently (prefixes). The lookup reads the + // Zen plane because every route below posts to a Zen URL, and the injector + // reads its plane from that URL (the plane-blind row is its fallback), so + // the set and the injector agree on what is free. for (const id of [ "mimo-v2.6-flash-free", "big-pickle", "beam-free", "muse-spark-1.2-contributor-free", ]) { - if (findModelSpec(id)?.is_free === true) { + if (findModelSpecOn("zen", id)?.is_free === true) { ids.add(id); } } @@ -123,6 +128,6 @@ describe("free-tier contract", () => { } } it("big-pickle is free by catalog flag, not by marker", () => { - expect(findModelSpec("big-pickle")?.is_free).toBe(true); + expect(findModelSpecOn("zen", "big-pickle")?.is_free).toBe(true); }); }); diff --git a/test/freshness-scope.test.ts b/test/freshness-scope.test.ts new file mode 100644 index 00000000..806120da --- /dev/null +++ b/test/freshness-scope.test.ts @@ -0,0 +1,941 @@ +/** + * Which freshness checks a pull request is gated on (`scripts/freshness-scope.ts`), + * and the CI wiring that acts on the answer. + * + * Three layers, each performed rather than asserted as a constant: the decision + * for each check on the three kinds of diff that matter (a generator change, a + * refresh, an unrelated change); the import walk over the REAL generator + * sources, read from disk, with a case whose answer is already known; and the + * workflow, parsed as data, with every step condition EVALUATED for each event + * and each scope rather than matched as text. + * + * @module test/freshness-scope.test + */ +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +import { + FRESHNESS_CHECKS, + type FreshnessCheck, + GENERATED_OUTPUTS, + handEditedOutputs, + handEditWarning, + importSpecifiers, + inputsOf, + isFreshnessCheck, + parseChangedFiles, + resolveLocalImport, + scopeFor, + type SourceReader, +} from "../scripts/freshness-scope.ts"; +import { GENERATED_TABLES } from "../scripts/vendor-version.ts"; + +const ROOT = join(import.meta.dirname, ".."); + +/** Every path the disk reader was asked for, so a case can prove it read. */ +const diskReader = (): { read: SourceReader; asked: string[] } => { + const asked: string[] = []; + const read: SourceReader = (path: string): string | undefined => { + asked.push(path); + try { + return readFileSync(join(ROOT, path), "utf8"); + } catch { + return undefined; + } + }; + return { asked, read }; +}; + +/** A reader over an in-memory tree, so a case can move a generator's imports. */ +const treeReader = + (tree: Readonly>): SourceReader => + (path) => + tree[path]; + +const CHECKS = Object.keys(FRESHNESS_CHECKS).filter(isFreshnessCheck); + +/** + * The files commit 2ebaffc changed: a refresh of both generated files, plus the + * tests and docs that follow the vendor's rename. It is the case that turned + * every open pull request red, so it is the case that must now report. + */ +const REFRESH_ONLY = [ + "README.md", + "README.zh-CN.md", + "docs/redirect-planes.md", + "src/catalog-data.ts", + "src/go-limits-data.ts", + "test/attachment-services.test.ts", + "test/catalog.test.ts", + "test/go-google-prepare.test.ts", + "test/go-google.test.ts", + "test/models-discovery.test.ts", + "test/responses-routes.test.ts", + "test/usage-panel.test.tsx", +]; + +const UNRELATED = ["src/usage-pill.tsx", "test/usage-pill-mount.test.tsx"]; + +describe("freshness scope · the walk reads the real generators", () => { + it("finds the catalog parser behind the catalog generator, and proves it read the entry", () => { + const { asked, read } = diskReader(); + const inputs = inputsOf("catalog:shim", read); + // Known answers: the generator itself, the parser it calls, and what the + // parser imports in turn - one level down is what makes it transitive. + expect(inputs).toContain("scripts/regenerate-catalog-shim.ts"); + expect(inputs).toContain("src/models-catalog.ts"); + expect(inputs).toContain("src/utils/guards.ts"); + // `src/config.ts` is loaded (`DEFAULT_GATEWAY_URLS`), so a settings change + // gates this check; `src/session-cost.ts` is reached only through + // `import type`, which loads nothing, so it does not. + expect(inputs).toContain("src/config.ts"); + expect(importSpecifiers(read("src/models-catalog.ts") ?? "")).not.toContain( + "./session-cost.ts" + ); + expect(read("src/models-catalog.ts")).toMatch( + /^import type \{[^}]*\} from "\.\/session-cost\.ts";$/mu + ); + expect(inputs).not.toContain("src/session-cost.ts"); + expect(asked).toContain("scripts/regenerate-catalog-shim.ts"); + expect(asked).toContain("src/models-catalog.ts"); + }); + + it("finds the limits parser behind the limits generator", () => { + const { read } = diskReader(); + const inputs = inputsOf("limits:shim", read); + expect(inputs).toContain("scripts/generate-go-limits.ts"); + expect(inputs).toContain("src/go-limits.ts"); + // The generator's source holds `import type … from "./go-limits.ts"`, but + // as template text: the line it writes into the file it emits, not an + // import of the generator. The premise is checked first, so the second + // assertion has something to miss. (The line is also type-only; the + // known-tree case below separates template text from that on its own.) + const source = read("scripts/generate-go-limits.ts") ?? ""; + expect(source).toMatch( + /^import type \{[^}]*\} from "\.\/go-limits\.ts";$/mu + ); + expect(importSpecifiers(source)).not.toContain("./go-limits.ts"); + }); + + it("counts the pinned version's file as a CLI-version input, though nothing imports it", () => { + const { read } = diskReader(); + // The rules moved into a pure half the script imports, so that module is + // an input too: a change to the rules gates the check that runs them. + expect(inputsOf("cli:version", read)).toStrictEqual([ + "scripts/check-vendor-version.ts", + "scripts/vendor-version.ts", + "src/identity.ts", + ]); + }); + + it("never counts a generated file as an input, though both generators import one", () => { + const { read } = diskReader(); + // The premise, checked on disk: each generator really does import a + // generated file, so the exclusion below is doing work. + expect( + importSpecifiers(read("scripts/regenerate-catalog-shim.ts") ?? "") + ).toContain("../src/catalog-data.ts"); + expect( + importSpecifiers(read("scripts/generate-go-limits.ts") ?? "") + ).toContain("../src/catalog-data.ts"); + for (const check of CHECKS) { + const inputs = inputsOf(check, read); + for (const output of GENERATED_OUTPUTS) { + expect(inputs, `${check} counts ${output}`).not.toContain(output); + } + } + }); + + it("moves the inputs when a generator gains an import", () => { + const { read } = diskReader(); + const entry = "scripts/generate-go-limits.ts"; + const before = inputsOf("limits:shim", read); + expect(before).not.toContain("src/utils/guards.ts"); + const grown: SourceReader = (path) => + path === entry + ? `import { isRecord } from "../src/utils/guards.ts";\n${read(path) ?? ""}` + : read(path); + expect(inputsOf("limits:shim", grown)).toStrictEqual( + [...before, "src/utils/guards.ts"].toSorted() + ); + }); + + it("agrees, on every real input, with an independent line-anchored reading of what Node keeps", () => { + // The walk reads edges with a lexer over Node's own stripped source. The + // oracle here is a different technique over the same stripped source: a + // module specifier on a line that STARTS an import or export statement (or + // closes a multi-line one). Where the two disagree on a local file, one of + // them is wrong - in either direction: an edge the walk dropped turns a + // gate into a report, and an edge it invented gates for nothing. + const { asked, read } = diskReader(); + const inputs = [ + ...new Set(CHECKS.flatMap((check) => inputsOf(check, read))), + ]; + expect(inputs.length).toBeGreaterThan(5); + expect(asked).toContain("src/models-catalog.ts"); + const entries = new Set( + CHECKS.flatMap((check) => FRESHNESS_CHECKS[check].dataInputs) + ); + const local = (importer: string, specifiers: string[]): string[] => + [ + ...new Set( + specifiers.flatMap((specifier) => { + const target = resolveLocalImport(importer, specifier, read); + return target === undefined ? [] : [target]; + }) + ), + ].toSorted(); + for (const file of inputs.filter((input) => !entries.has(input))) { + const source = read(file) ?? ""; + const lineAnchored = [ + ...stripTypeScriptTypes(source).matchAll( + /^\s*(?:(?:import|export)\b|\})[^\n]*?\bfrom\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']/gmu + ), + ].map((match) => match[1] ?? match[2] ?? ""); + expect( + local(file, importSpecifiers(source)), + `${file}: the walk's local edges against the line-anchored reading` + ).toStrictEqual(local(file, lineAnchored)); + } + }); + + it("refuses a walk that could not read its entry", () => { + expect(() => inputsOf("catalog:shim", treeReader({}))).toThrow( + /cannot read scripts\/regenerate-catalog-shim\.ts/u + ); + }); +}); + +describe("freshness scope · the import walk on a known tree", () => { + const tree = { + "scripts/regenerate-catalog-shim.ts": [ + '// import { notReal } from "../src/commented.ts";', + '/* import "../src/also-commented.ts"; */', + 'import { readFileSync } from "node:fs";', + 'import { a } from "../src/a.ts";', + 'import type { B } from "../src/b";', + 'export type { T } from "../src/types-only.ts";', + 'import { type K } from "../src/kept.ts";', + 'import "../src/side-effect.ts";', + 'const lazy = await import("./lazy.ts");', + 'import { OPENCODE_GO_CATALOG } from "../src/catalog-data.ts";', + // A string and a regex that each hold `/*`: a comment-stripping pattern + // took them for a comment's start and swallowed every edge up to the + // next `*/`. A lexer reads them as what they are. + 'const GLOB = "catalog/*";', + 'export { e } from "../src/after-glob.ts";', + String.raw`const trim = (url: string): string => url.replace(/\/*$/u, "");`, + 'export { f } from "../src/after-regex.ts";', + "/** the doc block whose close ended the fake comment */", + // An import written into a template is text the script emits, not an + // edge of the script itself. + 'const emitted = `\nimport { g } from "../src/in-template.ts";\n`;', + ].join("\n"), + "scripts/lazy.ts": "", + "src/a.ts": 'export { c } from "./nested/index.ts";\nimport z from "pkg";', + "src/b.ts": 'import { a } from "./a.ts";', + "src/catalog-data.ts": 'import { hidden } from "./only-via-output.ts";', + "src/nested/index.ts": 'import { d } from "../d.tsx";', + "src/d.tsx": "", + "src/kept.ts": "", + "src/types-only.ts": "", + "src/only-via-output.ts": "", + "src/side-effect.ts": "", + "src/after-glob.ts": "", + "src/after-regex.ts": "", + "src/in-template.ts": "", + }; + + it("follows every loading import form, transitively, from each importer's own directory", () => { + // `src/b.ts` and `src/types-only.ts` are reached only by a whole-statement + // `import type` / `export type`, which loads nothing; `src/kept.ts` is + // reached by `import { type K }`, which strips to `import {}` and still + // loads the module. `src/after-glob.ts` and `src/after-regex.ts` follow a + // `/*` inside a string and a regex; `src/in-template.ts` is named only + // inside a template, so it is no input. `scripts/lazy.ts` is reached by a + // dynamic `import()`, which loads it as surely as a static one. + expect(inputsOf("catalog:shim", treeReader(tree))).toStrictEqual([ + "scripts/lazy.ts", + "scripts/regenerate-catalog-shim.ts", + "src/a.ts", + "src/after-glob.ts", + "src/after-regex.ts", + "src/d.tsx", + "src/kept.ts", + "src/nested/index.ts", + "src/side-effect.ts", + ]); + }); + + it("tells a type-only statement from a type alias and an inline type", () => { + expect( + importSpecifiers( + [ + 'import type { A } from "./a.ts";', + 'import type * as N from "./n.ts";', + 'import type D from "./d.ts";', + 'export type * from "./s.ts";', + // A default import NAMED `type` is a real one. + 'import type from "./t.ts";', + 'import { type K, v } from "./k.ts";', + // An alias without its semicolon must not swallow the next import. + "export type Alias = { a: string }", + 'import { c } from "./c.ts";', + ].join("\n") + ).toSorted() + ).toStrictEqual(["./c.ts", "./k.ts", "./t.ts"]); + }); + + it("neither counts a generated file nor walks into it", () => { + const inputs = inputsOf("catalog:shim", treeReader(tree)); + expect(inputs).not.toContain("src/catalog-data.ts"); + expect(inputs).not.toContain("src/only-via-output.ts"); + }); + + it("counts a JSON a generator imports as a leaf input, and does not lex it", () => { + // Node loads this import under the generators' own flag, so the walk must + // follow it - and must not hand JSON to the TypeScript stripper, which + // would throw. `{ "a": "./not-an-edge.ts" }` would be an edge if lexed. + const jsonTree = { + "package.json": '{ "version": "1.0.0", "a": "./not-an-edge.ts" }', + "not-an-edge.ts": "", + "scripts/check-vendor-version.ts": + 'import pkg from "../package.json" with { type: "json" };', + "src/identity.ts": "", + }; + expect(inputsOf("cli:version", treeReader(jsonTree))).toStrictEqual([ + "package.json", + "scripts/check-vendor-version.ts", + "src/identity.ts", + ]); + }); + + it("names the file when a reached module cannot be stripped", () => { + // An `enum` is what Node's strip mode refuses; the walk fails closed, and + // the message says where to look. + const enumTree = { + "scripts/regenerate-catalog-shim.ts": 'import { E } from "../src/e.ts";', + "src/e.ts": "export enum E { A }", + }; + expect(() => inputsOf("catalog:shim", treeReader(enumTree))).toThrow( + /^src\/e\.ts: /u + ); + }); + + it("resolves only local specifiers that name a file", () => { + const read = treeReader(tree); + expect(resolveLocalImport("src/a.ts", "pkg", read)).toBeUndefined(); + expect(resolveLocalImport("src/a.ts", "node:fs", read)).toBeUndefined(); + expect( + resolveLocalImport("src/a.ts", "./missing.ts", read) + ).toBeUndefined(); + expect(resolveLocalImport("src/a.ts", "./nested", read)).toBe( + "src/nested/index.ts" + ); + expect(resolveLocalImport("src/nested/index.ts", "../d", read)).toBe( + "src/d.tsx" + ); + }); +}); + +describe("freshness scope · the decision", () => { + const { read } = diskReader(); + const inputs = Object.fromEntries( + CHECKS.map((check) => [check, inputsOf(check, read)]) + ) as Record; + + const GENERATOR_CHANGE: Record = { + "catalog:shim": ["src/models-catalog.ts", "src/catalog-data.ts"], + "cli:version": ["src/identity.ts", "README.md"], + "limits:shim": ["scripts/generate-go-limits.ts", "src/go-limits-data.ts"], + }; + + for (const check of CHECKS) { + it(`${check}: a pull request touching its inputs gates`, () => { + expect(scopeFor(GENERATOR_CHANGE[check], inputs[check])).toBe("gate"); + // The entry alone is enough. + expect(scopeFor([FRESHNESS_CHECKS[check].entry], inputs[check])).toBe( + "gate" + ); + }); + + it(`${check}: a pull request carrying only a refresh reports`, () => { + expect(scopeFor(REFRESH_ONLY, inputs[check])).toBe("report"); + }); + + it(`${check}: an unrelated pull request reports, and so does an empty diff`, () => { + expect(scopeFor(UNRELATED, inputs[check])).toBe("report"); + expect(scopeFor([], inputs[check])).toBe("report"); + }); + } + + it("cli:version: a docs-only diff reports - its copies are gated by the separate offline step", () => { + // The registry half of cli:version follows the vendor; a stale COPY in a + // doc is the repository's own, and `cli:version -- --copies` gates every + // pull request on it (the wiring and the script cases below). + expect(scopeFor(["docs/deep-dive.md"], inputs["cli:version"])).toBe( + "report" + ); + }); + + it("gates one check without gating the others", () => { + const changed = ["src/go-limits.ts"]; + expect(scopeFor(changed, inputs["limits:shim"])).toBe("gate"); + expect(scopeFor(changed, inputs["catalog:shim"])).toBe("report"); + expect(scopeFor(changed, inputs["cli:version"])).toBe("report"); + }); + + it("names a generated file changed without its generator, and only then", () => { + expect( + handEditedOutputs("catalog:shim", REFRESH_ONLY, inputs["catalog:shim"]) + ).toStrictEqual(["src/catalog-data.ts"]); + expect( + handEditedOutputs("limits:shim", REFRESH_ONLY, inputs["limits:shim"]) + ).toStrictEqual(["src/go-limits-data.ts"]); + // A generator change is expected to carry its output: no warning. + expect( + handEditedOutputs( + "catalog:shim", + GENERATOR_CHANGE["catalog:shim"], + inputs["catalog:shim"] + ) + ).toStrictEqual([]); + expect( + handEditedOutputs("cli:version", REFRESH_ONLY, inputs["cli:version"]) + ).toStrictEqual([]); + expect( + handEditedOutputs("catalog:shim", UNRELATED, inputs["catalog:shim"]) + ).toStrictEqual([]); + }); + + it("writes the warning as an annotation on the file it names", () => { + expect(handEditWarning("catalog:shim", "src/catalog-data.ts")).toMatch( + /^::warning file=src\/catalog-data\.ts::src\/catalog-data\.ts changed.*pnpm run catalog:shim -- --write/u + ); + }); + + it("reads git's changed-file list, one path per line", () => { + expect(parseChangedFiles("a.ts\r\nb.ts\n\n c.ts \n")).toStrictEqual([ + "a.ts", + "b.ts", + "c.ts", + ]); + }); +}); + +/** A GitHub Actions `if:` expression, evaluated over the few names the job uses. */ +const evaluate = ( + expression: string, + names: Readonly> +): boolean | string => { + const tokens = + expression.match(/'[^']*'|==|!=|&&|\|\||[()!]|[A-Za-z_][\w.-]*/gu) ?? []; + const consumed = tokens.join(""); + if (consumed !== expression.replaceAll(/\s+/gu, "")) { + throw new Error(`unparsed text in: ${expression}`); + } + let at = 0; + const truthy = (value: boolean | string): boolean => + typeof value === "boolean" ? value : value !== ""; + const primary = (): boolean | string => { + const token = tokens[at]; + at += 1; + if (token === "(") { + const value = or(); + const closing = tokens[at]; + at += 1; + if (closing !== ")") throw new Error(`unbalanced: ${expression}`); + return value; + } + if (token === "!") return !truthy(primary()); + if (token?.startsWith("'")) return token.slice(1, -1); + if (token !== undefined && Object.hasOwn(names, token)) { + return names[token] ?? ""; + } + throw new Error(`unknown name ${token} in: ${expression}`); + }; + const comparison = (): boolean | string => { + const left = primary(); + const op = tokens[at]; + if (op === "==" || op === "!=") { + at += 1; + const right = primary(); + return op === "==" ? left === right : left !== right; + } + return left; + }; + const and = (): boolean | string => { + let value = comparison(); + while (tokens[at] === "&&") { + at += 1; + const right = comparison(); + value = truthy(value) && truthy(right); + } + return value; + }; + const or = (): boolean | string => { + let value = and(); + while (tokens[at] === "||") { + at += 1; + const right = and(); + value = truthy(value) || truthy(right); + } + return value; + }; + const result = or(); + if (at !== tokens.length) throw new Error(`trailing tokens: ${expression}`); + return truthy(result); +}; + +describe("freshness scope · the CI wiring, parsed", () => { + interface Step { + readonly id?: string; + readonly if?: string; + readonly name?: string; + readonly run?: string; + readonly uses?: string; + readonly with?: Record; + } + const workflow = parse( + readFileSync(join(ROOT, ".github", "workflows", "ci.yml"), "utf8") + ) as { jobs: Record }; + const steps = workflow.jobs.catalog?.steps ?? []; + + const scopeStep = steps.find((step) => step.id === "scope"); + /** `decide ` lines in the scope step: key -> check. */ + const keys = Object.fromEntries( + [...(scopeStep?.run ?? "").matchAll(/^\s*decide (\w+) (\S+)\s*$/gmu)].map( + (match) => [match[2], match[1]] + ) + ) as Record; + + /** + * Every step that runs one check in full: minus the nightly `--write` + * refresh, and minus the CLI version's offline `--copies` gate, which is + * pinned on its own below. + */ + const runsOf = (check: FreshnessCheck): Step[] => + steps.filter( + (step) => + step.run !== undefined && + new RegExp(`pnpm run ${check}(?! -- --(?:write|copies))\\b`, "u").test( + step.run + ) + ); + + const runs = ( + step: Step, + event: string, + outputs: Readonly> + ): boolean => + step.if === undefined + ? true + : evaluate(step.if, { + "github.event_name": event, + ...Object.fromEntries( + ["catalog", "limits", "cli"].map((key) => [ + `steps.scope.outputs.${key}`, + outputs[key] ?? "", + ]) + ), + }) === true; + + it("read the job: the checkout reaches the base, and the scope step decides each check", () => { + expect(steps.length).toBeGreaterThan(5); + const checkout = steps.find((step) => + step.uses?.startsWith("actions/checkout@") + ); + expect(checkout?.with?.["fetch-depth"]).toBe(2); + expect(scopeStep?.if).toBe("github.event_name == 'pull_request'"); + expect(scopeStep?.run).toContain("git diff --name-only HEAD^1 HEAD"); + expect(scopeStep?.run).toContain("scripts/freshness-scope.ts"); + expect(Object.keys(keys).toSorted()).toStrictEqual([...CHECKS].toSorted()); + }); + + /** + * One step's `run` text, executed the way the runner executes it (`bash -e`, + * GitHub's default shell, which has no pipefail), in the repository, with a + * shell function named in `prelude` standing in for a command the step calls. + */ + const execute = ( + run: string, + prelude: string, + env: Readonly> = {} + ): { + status: number | null; + stdout: string; + stderr: string; + output: string; + calls: string; + } => { + const temp = mkdtempSync(join(tmpdir(), "ci-step-")); + try { + const result = spawnSync("bash", ["-e", "-c", `${prelude}\n${run}`], { + cwd: ROOT, + encoding: "utf8", + env: { + ...process.env, + ...env, + CALLS: join(temp, "calls"), + GITHUB_OUTPUT: join(temp, "github-output"), + PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: temp, + }, + }); + const readOr = (path: string): string => { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } + }; + return { + calls: readOr(join(temp, "calls")), + output: readOr(join(temp, "github-output")), + status: result.status, + stderr: result.stderr, + stdout: result.stdout, + }; + } finally { + rmSync(temp, { force: true, recursive: true }); + } + }; + + /** `pnpm` as a function that records its arguments and fails, naming a cause. */ + const FAILING_PNPM = + 'pnpm() { echo "$*" >> "$CALLS"; echo "error: boom" >&2; return 1; }'; + const PASSING_PNPM = 'pnpm() { echo "$*" >> "$CALLS"; return 0; }'; + + /** Gate or report, by what the step DOES when the check fails. */ + const classify = (step: Step): "gate" | "report" => { + const failing = execute(step.run ?? "", FAILING_PNPM); + if (failing.status !== 0) { + return "gate"; + } + expect(failing.stdout, step.name).toMatch(/^::warning::/mu); + return "report"; + }; + + it("runs the scope step's own shell and the CLI it calls: a generator change gates, a refresh reports", () => { + expect(scopeStep?.run).toBeDefined(); + // `git` as a function: it records what the step asked for and answers the + // diff from a file, so the real CLI reads a known change list. + const scopeOf = (changed: readonly string[]) => { + const diff = join( + mkdtempSync(join(tmpdir(), "ci-diff-")), + "changed-files" + ); + writeFileSync(diff, changed.map((file) => `${file}\n`).join("")); + try { + return execute( + scopeStep?.run ?? "", + 'git() { echo "$*" >> "$CALLS"; cat "$FIXTURE_DIFF"; }', + { FIXTURE_DIFF: diff } + ); + } finally { + rmSync(dirname(diff), { force: true, recursive: true }); + } + }; + + const generator = scopeOf(["scripts/regenerate-catalog-shim.ts"]); + expect(generator.stderr).toBe(""); + expect(generator.status).toBe(0); + expect(generator.calls).toBe("diff --name-only HEAD^1 HEAD\n"); + expect(generator.output).toBe("catalog=gate\nlimits=report\ncli=report\n"); + // The output keys are the ones the `decide` lines name, in their order - + // and those are the keys the `if:` conditions read (the cases below). + expect( + [...generator.output.matchAll(/^(\w+)=/gmu)].map((match) => match[1]) + ).toStrictEqual(Object.values(keys)); + + const refresh = scopeOf(["src/catalog-data.ts", "src/go-limits-data.ts"]); + expect(refresh.status).toBe(0); + expect(refresh.output).toBe("catalog=report\nlimits=report\ncli=report\n"); + expect(refresh.stdout).toContain( + "::warning file=src/catalog-data.ts::src/catalog-data.ts changed" + ); + expect(refresh.stdout).toContain( + "::warning file=src/go-limits-data.ts::src/go-limits-data.ts changed" + ); + // The CLI's warnings are its own: Node's experimental notice is silenced. + expect(refresh.stdout).not.toContain("ExperimentalWarning"); + + const pin = scopeOf(["src/identity.ts"]); + expect(pin.output).toBe("catalog=report\nlimits=report\ncli=gate\n"); + }); + + it("the CLI refuses an unknown check with a usage line and exit 2", () => { + const run = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(ROOT, "scripts", "freshness-scope.ts"), + "catalog", + ], + { cwd: ROOT, encoding: "utf8", input: "src/catalog-data.ts\n" } + ); + expect(run.status).toBe(2); + expect(run.stdout).toBe(""); + expect(run.stderr).toMatch(/^usage: freshness-scope\.ts { + // Node realpaths the main module's URL but only resolves `argv[1]`; a + // run-as-script guard comparing the two printed nothing and exited 0. + const temp = mkdtempSync(join(tmpdir(), "freshness-link-")); + try { + const link = join(temp, "scripts"); + symlinkSync(join(ROOT, "scripts"), link, "dir"); + const run = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(link, "freshness-scope.ts"), + "catalog:shim", + ], + { + cwd: ROOT, + encoding: "utf8", + input: "scripts/regenerate-catalog-shim.ts\n", + } + ); + expect(run.stderr).toBe(""); + expect(run.stdout).toBe("gate\n"); + expect(run.status).toBe(0); + } finally { + rmSync(temp, { force: true, recursive: true }); + } + }); + + it("the scope step runs before every step that reads its outputs", () => { + const at = steps.findIndex((step) => step.id === "scope"); + expect(at).toBeGreaterThan(-1); + const readers = steps + .map((step, index) => ({ index, step })) + .filter(({ step }) => step.if?.includes("steps.scope.outputs.") === true); + expect(readers.length).toBeGreaterThanOrEqual(6); + for (const { index, step } of readers) { + expect(index, step.name).toBeGreaterThan(at); + } + }); + + for (const check of CHECKS) { + it(`${check}: a pull request runs exactly one of its gate and its report, gating unless the answer is "report"`, () => { + const key = keys[check] ?? ""; + const candidates = runsOf(check); + expect(candidates).toHaveLength(2); + // Performed: each step run with a failing check, and with a passing one. + const gates = candidates.filter((step) => classify(step) === "gate"); + const reports = candidates.filter((step) => classify(step) === "report"); + expect(gates, `${check} gate steps`).toHaveLength(1); + expect(reports, `${check} report steps`).toHaveLength(1); + const [gate] = gates; + const [report] = reports; + for (const step of [gate, report]) { + const passing = execute(step?.run ?? "", PASSING_PNPM); + expect(passing.status, step?.name).toBe(0); + expect(passing.stdout, step?.name).not.toMatch( + /::(?:warning|error)::/u + ); + expect(passing.calls.split("\n")[0], step?.name).toBe(`run ${check}`); + } + + // Fail closed: '' is a lost answer (a renamed output, a moved step). + const other = { catalog: "report", cli: "report", limits: "report" }; + for (const scope of ["", "gate", "report"]) { + const outputs = { ...other, [key]: scope }; + expect(runs(gate!, "pull_request", outputs), `gate on '${scope}'`).toBe( + scope !== "report" + ); + expect( + runs(report!, "pull_request", outputs), + `report on '${scope}'` + ).toBe(scope === "report"); + } + // The scope step does not run on a push: its outputs are empty there. + expect(runs(gate!, "push", {})).toBe(false); + expect(runs(report!, "push", {})).toBe(true); + // The nightly is unchanged: never gated; it reports the CLI version and + // regenerates the shims in its own step. + expect(runs(gate!, "schedule", {})).toBe(false); + expect(runs(report!, "schedule", {})).toBe(check === "cli:version"); + // A manual dispatch, as the job's comment says: no gate, and only the + // CLI version reports. + expect(runs(gate!, "workflow_dispatch", {})).toBe(false); + expect(runs(report!, "workflow_dispatch", {})).toBe( + check === "cli:version" + ); + }); + } + + it("cli:version's offline copy scan gates every pull request, whatever the scope", () => { + const copies = steps.filter((step) => + /pnpm run cli:version -- --copies\b/u.test(step.run ?? "") + ); + expect(copies).toHaveLength(1); + const [step] = copies; + // A gate: its failure fails the step. + expect(classify(step!)).toBe("gate"); + for (const scope of ["", "gate", "report"]) { + expect(runs(step!, "pull_request", { cli: scope }), scope).toBe(true); + } + // Elsewhere the full check's report step runs the scan too. + expect(runs(step!, "push", {})).toBe(false); + }); + + it("the CLI report step quotes the script's own cause, and needs its pipefail to", () => { + const report = runsOf("cli:version").find( + (step) => classify(step) === "report" + ); + const run = report?.run ?? ""; + const warned = execute(run, FAILING_PNPM); + expect(warned.status).toBe(0); + expect(warned.stdout).toMatch(/^::warning::error: boom$/mu); + // The control: the same step without its pipefail never warns, because + // `tee` answers for the pipeline. + const withoutPipefail = run.replace(/^\s*set -o pipefail\s*$/mu, ""); + expect(withoutPipefail).not.toBe(run); + const silent = execute(withoutPipefail, FAILING_PNPM); + expect(silent.status).toBe(0); + expect(silent.stdout).not.toContain("::warning::"); + }); + + it("each check's entry is the script package.json runs for it", () => { + const { scripts } = JSON.parse( + readFileSync(join(ROOT, "package.json"), "utf8") + ) as { scripts: Record }; + for (const check of CHECKS) { + expect(scripts[check]?.split(/\s+/u), check).toContain( + FRESHNESS_CHECKS[check].entry + ); + } + }); + + it("the evaluator answers the cases it is trusted with", () => { + expect( + evaluate("a == 'x' && (b == 'y' || !c)", { a: "x", b: "z", c: "" }) + ).toBe(true); + expect(evaluate("a != 'x'", { a: "x" })).toBe(false); + expect(() => evaluate("steps.scop.outputs.catalog == 'gate'", {})).toThrow( + /unknown name/u + ); + }); +}); + +describe("cli:version · the offline copy scan (`--copies`)", () => { + const SCRIPT = join(ROOT, "scripts", "check-vendor-version.ts"); + /** + * Where the pin lives, as freshness-scope counts it: the fixture writes it + * THERE, so a passing run proves the script reads the very file the scope + * treats as this check's data input, and moving one without the other fails. + */ + const PIN_FILE = FRESHNESS_CHECKS["cli:version"].dataInputs[0] ?? ""; + /** + * A version only the vendor's data says. Built from parts: the real copy + * scan reads this test file, so a spelled-out CLI literal here would fail + * the very gate it is testing. + */ + const VENDOR_ONLY = [9, 9, 9].join("."); + + /** + * A throwaway tree with a pin, one doc copy, and each generated table + * carrying a version that disagrees with the pin - vendor data, which the + * scan must skip - and the script run in it. + */ + const runIn = ( + pin: string, + copy: string, + args: readonly string[] + ): { status: number | null; stdout: string; stderr: string } => { + const root = mkdtempSync(join(tmpdir(), "cli-version-")); + try { + for (const [path, text] of Object.entries({ + "README.md": "# fixture\n", + "docs/x.md": `User-Agent: opencode/${copy}\n`, + [PIN_FILE]: `export const OPENCODE_CLI_VERSION = "${pin}";\n`, + ...Object.fromEntries( + GENERATED_OUTPUTS.map((output) => [ + output, + // Assembled, never spelled: this file is in the real scan's path. + `export const ROW = { ua: "${["opencode", VENDOR_ONLY].join("/")}", seen: "v${pin.split(".").slice(0, 2).join(".")}.99" };\n`, + ]) + ), + })) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); + } + const run = spawnSync( + process.execPath, + ["--experimental-strip-types", SCRIPT, ...args], + { cwd: root, encoding: "utf8" } + ); + return { status: run.status, stderr: run.stderr, stdout: run.stdout }; + } finally { + rmSync(root, { force: true, recursive: true }); + } + }; + + it("skips exactly the files the scope calls generated", () => { + // Two lists, kept apart on purpose (an import would make the scope module an + // input of this check); this is what keeps them the same list. + expect([...GENERATED_TABLES].toSorted()).toStrictEqual( + [...GENERATED_OUTPUTS].toSorted() + ); + }); + + it("passes on this repository itself, as CI's pull-request gate will run it", () => { + // Offline, and the same scan CI gates every pull request on: a stale copy + // anywhere in the tree - this file included - fails here first. + const run = spawnSync( + process.execPath, + ["--experimental-strip-types", SCRIPT, "--copies"], + { cwd: ROOT, encoding: "utf8" } + ); + expect(run.stderr).toBe(""); + expect(run.status).toBe(0); + // A known answer: the tree carries copies, so the scan read something. + expect(run.stdout).toMatch(/^ok {3}[1-9]\d* copy\/copies /u); + }); + + it("fails on a copy that disagrees with the pin, naming the cause on its first line", () => { + const run = runIn("0.0.1", "0.0.0", ["--copies"]); + expect(run.status).toBe(1); + expect(run.stderr.split("\n")[0]).toBe( + `error: these copies do not say 0.0.1, which is what ${PIN_FILE} declares:` + ); + expect(run.stderr).toContain("0.0.0 in docs/x.md"); + // The generated tables' disagreeing versions are not reported as copies. + for (const output of GENERATED_OUTPUTS) { + expect(run.stderr).not.toContain(output); + } + }); + + it("passes when the copies agree, without asking the registry, and skips the generated tables", () => { + // 0.0.1 was never published, so a registry comparison would fail: the + // pass proves the scan stopped before it. Both generated tables say a + // disagreeing CLI literal and a bare `v0.0.99`: the pass proves the scan + // skipped them, because a vendor move cannot be a stale copy. + expect(PIN_FILE).not.toBe(""); + const run = runIn("0.0.1", "0.0.1", ["--copies"]); + expect(run.stderr).toBe(""); + expect(run.status).toBe(0); + expect(run.stdout).toMatch( + /^ok {3}1 copy\/copies .*say 0\.0\.1.*registry not asked/u + ); + }); +}); diff --git a/test/go-discovery.test.ts b/test/go-discovery.test.ts index 92f6a811..dee81344 100644 --- a/test/go-discovery.test.ts +++ b/test/go-discovery.test.ts @@ -28,35 +28,31 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createOpencodeStore, + type FixtureCredential, + type FixtureStore, +} from "./support/opencode-store-fixture.ts"; import { DEFAULT_USAGE_BASE_URL, DEFAULT_USAGE_KEY_ENV, + type DiscoveredGoConfig, KEY_SOURCE_POLICIES, -} from "../src/config.ts"; -import { + clearCapturedApiKeys, declaredProviderModels, discoverGoConfig, effectiveGoKeyRef, + getCapturedApiKey, + isPlaceholderApiKey, + recordCapturedApiKey, resolveGoApiKey, resolveGoBaseURL, resolveGoKeyForRef, - resolveRoutedKey, + resolveOpencodeStoreKey, resolveZenCreditInfo, toGoBaseURL, - type DiscoveredGoConfig, -} from "../src/go-discovery.ts"; -import { - clearCapturedApiKeys, - getCapturedApiKey, - isPlaceholderApiKey, - recordCapturedApiKey, -} from "../src/key-capture.ts"; -import { - createMockContext, - createOpencodeStore, - type FixtureCredential, - type FixtureStore, -} from "./test-helpers.ts"; +} from "./support/src.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"; @@ -935,128 +931,6 @@ describe("resolveZenCreditInfo", () => { }); }); -describe("resolveRoutedKey", () => { - it("calls the Go route Go whatever the resolved key looks like", async () => { - // The routed provider is authoritative for the tier: a key that is a - // shared or vendored token still authenticates the Go plane when the route - // in use *is* the Go route. - process.env.OPENCODE_GO_API_KEY = "vendor-token-1234"; - const routed = await resolveRoutedKey(composition(), "opencode-go"); - expect(routed.key).toBe("vendor-token-1234"); - expect(routed.tier).toBe("go"); - 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("accepts a Zen key for a route that legitimately owns one", async () => { - // Unlike `resolveGoApiKey`, this lookup never rejects a Zen key: here the - // Zen tier is the target rather than a mistake. - process.env.OPENCODE_API_KEY = "oc_sk_zen-only"; - const routed = await resolveRoutedKey(composition(), "opencode"); - expect(routed.key).toBe("oc_sk_zen-only"); - expect(routed.tier).toBe("zen"); - expect( - await resolveGoKeyForRef(composition(), "OPENCODE_API_KEY") - ).toBeUndefined(); - }); - - it("takes the most recent captured key when the route has none of its own", 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. - 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"); - }); - - it("uses the route's own declared reference, defaulting each plane differently", async () => { - // The default reference differs by plane on purpose: a Go route must never - // silently fall back to the Zen reference. - const goProbe = withCredentials({}); - await resolveRoutedKey(goProbe.context, "opencode-go"); - expect(goProbe.asked).toEqual([DEFAULT_USAGE_KEY_ENV]); - - const zenProbe = withCredentials({}); - await resolveRoutedKey(zenProbe.context, "opencode"); - expect(zenProbe.asked).toEqual(["OPENCODE_API_KEY"]); - - const declaredProbe = withCredentials({}, [ - registryEntry("opencode-go", { apiKeyEnv: "DSH_TEST_GO_KEY" }), - ]); - await resolveRoutedKey(declaredProbe.context, "opencode-go", "configured"); - expect(declaredProbe.asked).toEqual(["DSH_TEST_GO_KEY"]); - }); - - it("reports a ten-character prefix, and none below it", async () => { - // The prefix identifies the account without disclosing it. The floor is the - // SAME ten characters as the slice: it used to be 8, so an 8- or 9-character - // key was reported WHOLE — a "prefix" that leaked the entire secret, - // precisely the length it was meant to withhold. - recordCapturedApiKey("sk-abcdefghijkl", "opencode-go", GO_REQUEST); - const long = await resolveRoutedKey(composition(), "opencode-go"); - expect(long.keyPrefix).toBe("sk-abcdefg"); - clearCapturedApiKeys(); - recordCapturedApiKey("sk-1234567890", "opencode-go", GO_REQUEST); - const atFloor = await resolveRoutedKey(composition(), "opencode-go"); - expect(atFloor.key).toBe("sk-1234567890"); - // Exactly ten characters, so the ninth and tenth digits stay hidden. - expect(atFloor.keyPrefix).toBe("sk-1234567"); - clearCapturedApiKeys(); - recordCapturedApiKey("sk-12345", "opencode-go", GO_REQUEST); - const belowFloor = await resolveRoutedKey(composition(), "opencode-go"); - // The key is still returned — only the derived prefix is withheld. - expect(belowFloor.key).toBe("sk-12345"); - expect(belowFloor.keyPrefix).toBeUndefined(); - }); - - it("carries both optional fields even when nothing resolved", async () => { - // Consumers destructure `key`/`keyPrefix`, so the fields must be present - // rather than absent — an absent field is a different shape to narrow. - const routed = await resolveRoutedKey(composition(), "opencode-go"); - expect(routed.key).toBeUndefined(); - expect(routed.keyPrefix).toBeUndefined(); - expect(Object.hasOwn(routed, "key")).toBe(true); - expect(Object.hasOwn(routed, "keyPrefix")).toBe(true); - }); - - it("applies the row's keySource policy exactly as the meter does", async () => { - // Both consumers share `KEY_SOURCE_ORDER`, so a policy that changed the - // meter's credential would change what every routed request is signed - // with; the two must not be able to drift. - const context = composition([ - registryEntry("opencode-go", { apiKey: "sk-literal" }), - ]); - process.env.OPENCODE_GO_API_KEY = "sk-env"; - recordCapturedApiKey("sk-captured", "opencode-go", GO_REQUEST); - const automatic = await resolveRoutedKey(context, "opencode-go", "auto"); - const requested = await resolveRoutedKey(context, "opencode-go", "request"); - const declared = await resolveRoutedKey( - context, - "opencode-go", - "configured" - ); - expect(automatic.key).toBe("sk-literal"); - expect(requested.key).toBe("sk-captured"); - expect(declared.key).toBe("sk-literal"); - }); -}); - describe("gaps characterised while pinning the policy", () => { it("never lets a placeholder become the credential the meter sends", async () => { // This repo's README tells users to write exactly `authorization: Bearer @@ -1093,8 +967,6 @@ describe("gaps characterised while pinning the policy", () => { expect(await resolveGoApiKey(context, "opencode-go", "request")).toBe( "sk-real-captured" ); - const routed = await resolveRoutedKey(context, "opencode-go", "request"); - expect(routed.key).toBe("sk-real-captured"); }); it("treats the gateway's keyless sentinel as not a credential", async () => { @@ -1352,16 +1224,10 @@ describe("go-discovery: the installation's own store, and where it sits", () => await expect( resolveGoApiKey(composition(), undefined, "auto", true) ).resolves.toBeUndefined(); - // ...while the Zen route resolves that same row. The plane is what the - // store is keyed by, and a reader that took the first row would hand the Go - // endpoint a Zen credential. - const routed = await resolveRoutedKey( - composition(), - "opencode", - "auto", - true - ); - expect(routed.key).toBe("oc_sk_fixture_zen"); + // ...while the Zen integration resolves that same row. The plane is what + // the store is keyed by, and a reader that took the first row would hand + // the Go endpoint a Zen credential. + expect(resolveOpencodeStoreKey("opencode", true)).toBe("oc_sk_fixture_zen"); }); it("answers undefined for a store that holds nothing", async () => { diff --git a/test/go-google-prepare.test.ts b/test/go-google-prepare.test.ts index 10389a22..79b08781 100644 --- a/test/go-google-prepare.test.ts +++ b/test/go-google-prepare.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveConfig } from "../src/config.ts"; -import { decoratePlaneAdapters } from "../src/models-discovery.ts"; +import { decoratePlaneAdapters, resolveConfig } from "./support/src.ts"; const model = "gemini-4-argon"; const route = "opencode-go-google"; diff --git a/test/go-google.test.ts b/test/go-google.test.ts index 4a69e91c..16c033ae 100644 --- a/test/go-google.test.ts +++ b/test/go-google.test.ts @@ -3,22 +3,20 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; import { describe, expect, it } from "vitest"; -import { OPENCODE_GO_CATALOG } from "../src/catalog-data.ts"; -import { resolveConfig } from "../src/config.ts"; -import { patchFetch } from "../src/fetch-patch.ts"; +import { testPi } from "./google-runtime-fixture.ts"; import { - registerGoogleBridge, + type ActiveTurnState, GO_GOOGLE_BASE_URL, -} from "../src/google-bridge.ts"; -import { hideResponsesRoute } from "../src/models-discovery.ts"; -import { GO_GOOGLE_INTERNAL_ROUTE, + OPENCODE_GO_CATALOG, + createStreamHook, + getSessionUsage, + hideResponsesRoute, internalRouteFor, -} from "../src/responses-routes.ts"; -import { getSessionUsage } from "../src/session-cost.ts"; -import { createStreamHook } from "../src/stream-hook.ts"; -import type { ActiveTurnState } from "../src/turn-store.ts"; -import { testPi } from "./google-runtime-fixture.ts"; + patchFetch, + registerGoogleBridge, + resolveConfig, +} from "./support/src.ts"; interface WirePart { functionCall?: { name: string }; diff --git a/test/go-limits-html.test.ts b/test/go-limits-html.test.ts index f1efe377..ed6e5fe1 100644 --- a/test/go-limits-html.test.ts +++ b/test/go-limits-html.test.ts @@ -17,7 +17,7 @@ import { describe, expect, it } from "vitest"; import { htmlToDoc, tableRows } from "../scripts/go-limits-html.ts"; -import { parseGoLimitsDoc } from "../src/go-limits.ts"; +import { parseGoLimitsDoc } from "./support/src.ts"; /** * A monthly-limit table, as the page renders one. diff --git a/test/go-limits.test.ts b/test/go-limits.test.ts index 66d6a5fe..c1cdade6 100644 --- a/test/go-limits.test.ts +++ b/test/go-limits.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; -import { GO_MODEL_LIMITS } from "../src/go-limits-data.ts"; import { - allowanceFor, - formatUsd, + GO_MODEL_LIMITS, GO_PLAN_TIERS, + WINDOW_SHARE, + allowanceFor, + formatAllowanceUsd as formatUsd, normalizeModelLabel, parseGoLimitsDoc, - WINDOW_SHARE, -} from "../src/go-limits.ts"; +} from "./support/src.ts"; /** The two tier tables, trimmed to what the parser reads. */ const doc = (go: string, plus: string): string => ` diff --git a/test/google-bridge.test.ts b/test/google-bridge.test.ts index 7260ac15..9506a9a0 100644 --- a/test/google-bridge.test.ts +++ b/test/google-bridge.test.ts @@ -20,18 +20,19 @@ import { } from "@earendil-works/pi-ai/providers/all"; import { describe, expect, it } from "vitest"; -import { OPENCODE_ZEN_CATALOG } from "../src/catalog-data.ts"; import { - ambientAuthContext, - emptyCredentialStore, + GOOGLE_INTERNAL_ROUTE, + GOOGLE_SDK, type GoogleBridgeInputs, + OPENCODE_ZEN_CATALOG, type PiRuntime, + ambientAuthContext, + emptyCredentialStore, googleProviderFor, loadHostPi, registerGoogleBridge, resolveGatewayKey, -} from "../src/google-bridge.ts"; -import { GOOGLE_INTERNAL_ROUTE, GOOGLE_SDK } from "../src/responses-routes.ts"; +} from "./support/src.ts"; /** The Gemini models of the Zen plane, straight from the generated catalog. */ const geminiSpecs = OPENCODE_ZEN_CATALOG.filter( @@ -62,12 +63,14 @@ interface ProviderLike { } /** - * The pi runtime the bridge consumes, built from THIS repo's devDependency. + * The pi runtime the bridge consumes, built from the root pi the suite imports. * * A deployment gets the harness's own pi (`loadHostPi` finds it through - * `dsh-llm-pi-ai`), and the shape is identical either way — these tests exercise - * the contract, not a version. `pi-runtime.test.ts` proves the loader finds the - * host's copy in the real tree. + * `dsh-llm-pi-ai`). In every channel's install (`test/support/host-versions.ts`) + * the root pi and the wrapper's are one copy, and these tests exercise the + * contract, not a version. `host-versions.test.ts` (its first two cases) + * proves the loader finds the host's copy in the real tree and that it is this + * same module. */ const testPi = (): PiRuntime => ({ getBuiltinModels: (plane) => @@ -456,9 +459,12 @@ describe("google-bridge · the pi copy it loads", () => { it("declares no pi of its own at runtime, and loads the host's", async () => { // The rule this replaced a version pin with: the plugin must NOT ship a pi. // The wrapper (`dsh-llm-pi-ai`) already depends on `@earendil-works/pi-ai` - // with a range, so the host installs the pi its own release was tested - // against. A second copy here would put two versions in one tree and turn - // every harness bump into a version-skew bug. + // with a range (`^1.1.0` on rc, `^1.3.0` on alpha): an npm install of the + // published host floats it to the newest 1.x, the desktop app ships the + // harness workspace's own pin, and this repo's dev install follows the + // channel's committed lockfile. A second copy here + // would put two versions in one tree and turn every harness bump into a + // version-skew bug. const ours = JSON.parse(readFileSync("package.json", "utf8")) as { dependencies?: Record; devDependencies?: Record; diff --git a/test/google-replay.test.ts b/test/google-replay.test.ts index ed276035..e89f99b9 100644 --- a/test/google-replay.test.ts +++ b/test/google-replay.test.ts @@ -4,7 +4,7 @@ import { googleHistoryFor, googleReplayFor, googleReplayStream, -} from "../src/utils/google-replay.ts"; +} from "./support/src.ts"; describe("Google replay route boundary", () => { it("leaves error chunks JSON-serializable and does not invent missing replay", async () => { diff --git a/test/google-runtime-fixture.ts b/test/google-runtime-fixture.ts index b1cb5d29..f6380b7b 100644 --- a/test/google-runtime-fixture.ts +++ b/test/google-runtime-fixture.ts @@ -1,7 +1,7 @@ import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; -import type { PiRuntime } from "../src/google-bridge.ts"; +import type { PiRuntime } from "./support/src.ts"; export const testPi = (): PiRuntime => ({ getBuiltinModels: (plane) => diff --git a/test/google-thinking.test.ts b/test/google-thinking.test.ts index b70da73c..0d1fb974 100644 --- a/test/google-thinking.test.ts +++ b/test/google-thinking.test.ts @@ -1,17 +1,16 @@ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; import { describe, expect, it } from "vitest"; +import { testPi } from "./google-runtime-fixture.ts"; import { + GOOGLE_INTERNAL_ROUTE, + GOOGLE_SDK, OPENCODE_ZEN_CATALOG, - type CatalogModelSpec, -} from "../src/catalog-data.ts"; -import type { PiRuntime } from "../src/google-bridge.ts"; -import { googleProviderFor, registerGoogleBridge, -} from "../src/google-bridge.ts"; -import { GOOGLE_INTERNAL_ROUTE, GOOGLE_SDK } from "../src/responses-routes.ts"; -import { testPi } from "./google-runtime-fixture.ts"; + type CatalogModelSpec, + type PiRuntime, +} from "./support/src.ts"; const ARGON_ID = "gemini-4-argon"; const argonSpec = OPENCODE_ZEN_CATALOG.find((spec) => spec.id === ARGON_ID); diff --git a/test/guards.test.ts b/test/guards.test.ts index fee80a60..0d91fde9 100644 --- a/test/guards.test.ts +++ b/test/guards.test.ts @@ -17,7 +17,7 @@ import { isFunctionLike, isRecord, isUnknownArray, -} from "../src/utils/guards.ts"; +} from "./support/src.ts"; /** A value that is genuinely absent, without writing the `undefined` literal. */ const ABSENT: unknown = undefined; diff --git a/test/harness-channel.test.ts b/test/harness-channel.test.ts index 78983289..448d0703 100644 --- a/test/harness-channel.test.ts +++ b/test/harness-channel.test.ts @@ -1,7 +1,8 @@ /** * Every harness package this repository compiles or peers against is pinned to - * ONE exact version, and the peer range it declares covers BOTH distribution - * channels of the lines this plugin supports. + * ONE exact version, the peer range it declares covers BOTH distribution + * channels of the lines this plugin supports, and each channel's committed + * lockfile installs exactly that channel's record. * * The alpha and `rc` channels are parallel tracks, not a version ordering: * `0.2.1-rc.2` sorts ABOVE `0.2.1-alpha.5` in semver, so a caret range written @@ -10,16 +11,21 @@ * harness packages on rc. Compile-time deps made that invisible; the tests below * are what make it visible. * - * Two rules, deliberately separate: + * Three rules, deliberately separate: * - * 1. PINS are exact and name the version the host runs. Only one version can be - * installed, so the pin is a fact about the host, not a preference — and it - * must be exact, because a range would float it onto a version nobody - * verified. + * 1. PINS are exact and name the version a channel's host runs. The root + * `package.json` and `pnpm-lock.yaml` install the alpha channel (the + * default); `channels/rc/pnpm-lock.yaml` installs rc through + * `scripts/use-channel.ts`. Both are records in + * `test/support/host-versions.ts` (D22''), and a range would float a pin + * onto a version nobody verified. * 2. PEERS describe the code, and the code targets the whole `0.2.x` line on * BOTH channels — the latest `rc` and the latest `alpha` alike — because a * host may be running either one. Narrowing a peer range to the channel that * happens to be pinned is the mistake this file exists to catch. + * 3. pi is the bridge's, at the version npm resolves, UNPATCHED (D14''). The + * plugin declares no pi range narrower than the union of the channels' + * ranges, and this repository carries no pi patch. * * `cordis` has its own line. The host vendors it as source at an exact version * which may or may not carry a channel tag (`4.0.5`, then `4.0.6-alpha.1`), so @@ -28,10 +34,19 @@ * * @module test/harness-channel */ -import { readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import semver from "semver"; import { describe, expect, it } from "vitest"; +import { parse, parseAllDocuments } from "yaml"; + +import { channelManifest, pinFor } from "../scripts/channel-manifest.ts"; +import { + type Channel, + CHANNELS, + DEFAULT_CHANNEL, + PI_UNION_RANGE, +} from "./support/host-versions.ts"; interface Manifest { dependencies?: Record; @@ -39,8 +54,10 @@ interface Manifest { peerDependencies?: Record; } -const manifest = (): Manifest => - JSON.parse(readFileSync("package.json", "utf8")) as Manifest; +const PI = "@earendil-works/pi-ai"; + +const manifestText = (): string => readFileSync("package.json", "utf8"); +const manifest = (): Manifest => JSON.parse(manifestText()) as Manifest; const EXACT = /^\d+\.\d+\.\d+(-[a-z]+\.\d+)?$/u; @@ -50,7 +67,97 @@ const harnessPins = (): [string, string][] => ...manifest().devDependencies, }).filter(([name]) => name.startsWith("@deepseek-ai/")); -describe("harness channel alignment", () => { +/** + * What one committed lockfile resolved: the root importer's direct + * `@deepseek-ai/*` and pi versions (peer suffix stripped), and every + * `packages:` key, transitive entries included. Read from the LAST YAML + * document, the project's (pnpm writes its own package-manager record first). + */ +const lockfile = (path: string) => { + const text = readFileSync(path, "utf8"); + const project = text.split(/^---$/mu).at(-1) ?? ""; + const importer = + project.split("\nimporters:\n")[1]?.split("\npackages:\n")[0] ?? ""; + const direct = new Map( + [ + ...importer.matchAll( + /^ {6}'?([^'\s]+?)'?:\n {8}specifier: .+\n {8}version: ([^(\n]+)/gmu + ), + ].map(([, name, version]) => [name ?? "", version ?? ""] as const) + ); + const packages = + project.split("\npackages:\n").at(-1)?.split("\nsnapshots:\n")[0] ?? ""; + const resolved = [ + ...packages.matchAll(/^ {2}'?(@?[^@'\s]+)@([^'(\s]+)'?:/gmu), + ].map(([, name, version]) => [name ?? "", version ?? ""] as const); + const versionsOf = (name: string): string[] => + resolved.filter(([key]) => key === name).map(([, version]) => version); + const snapshots = project.split("\nsnapshots:\n").at(-1) ?? ""; + return { direct, project, resolved, snapshots, text, versionsOf }; +}; + +/** + * The prerelease lines a channel's lockfile must not hold: each OTHER + * channel's prerelease tag. A channel whose `dsh` is a stable release has no + * prerelease line that could leak, so it contributes none (the rc line goes + * stable when `latest` reaches `0.2.1`). + */ +const foreignTags = ( + channel: string, + records: Readonly> +): string[] => + Object.entries(records) + .filter(([other]) => other !== channel) + .map(([, other]) => semver.prerelease(other.dsh)?.[0]) + .filter((tag): tag is string => typeof tag === "string"); + +describe("harness channel alignment · the other channel's prerelease line", () => { + it("reads each other channel's tag, and none from a stable one", () => { + expect(foreignTags("alpha", CHANNELS)).toStrictEqual(["rc"]); + expect(foreignTags("rc", CHANNELS)).toStrictEqual(["alpha"]); + // Known answers, so a broken reader cannot pass by reading nothing. + expect(foreignTags("x", { a: { dsh: "0.2.2-alpha.2" } })).toStrictEqual([ + "alpha", + ]); + expect(foreignTags("x", { a: { dsh: "0.2.1-rc.5" } })).toStrictEqual([ + "rc", + ]); + // The rc channel's stable release: nothing to look for, and no failure. + expect( + foreignTags("alpha", { ...CHANNELS, rc: { dsh: "0.2.1" } }) + ).toStrictEqual([]); + }); +}); + +describe("harness channel alignment · the READMEs' support policy", () => { + // CONTRIBUTING's release step 3: a channel bump moves the record and these + // sentences together, in both languages. Held here, so the bump that moves + // only the record fails rather than leaving either README stale. + const POLICY = [ + ["README.md", "**Support policy.**"], + ["README.zh-CN.md", "**支持策略。**"], + ] as const; + for (const [file, marker] of POLICY) { + it(`${file} names each channel's release, pi range and floor as its record does`, () => { + const lines = readFileSync(file, "utf8").split("\n"); + const at = lines.findIndex((line) => line.startsWith(marker)); + // Known answer: the paragraph was found, so the checks below read it. + expect(at, `${marker} in ${file}`).toBeGreaterThan(-1); + const policy = lines[at] ?? ""; + // The paragraph that follows names the suite's installs. + const installs = lines.slice(at + 1).find((line) => line !== "") ?? ""; + for (const channel of Object.keys(CHANNELS) as Channel[]) { + const { dsh, piFloor, piRange } = CHANNELS[channel]; + for (const value of [dsh, piRange, piFloor]) { + expect(policy, `${channel} ${value}`).toContain(`\`${value}\``); + } + expect(installs, `${channel} install`).toContain(`\`${dsh}\``); + } + }); + } +}); + +describe("harness channel alignment · declarations", () => { it("every harness dependency is an exact pin, never a range", () => { const pins = harnessPins(); expect(pins.length).toBeGreaterThan(0); @@ -62,11 +169,27 @@ describe("harness channel alignment", () => { } }); - it("the pins are on a channel the peer range admits", () => { + it("the committed manifest is the default channel's, and every harness package has a channel rule", () => { + // `channelManifest` is what `scripts/use-channel.ts` installs a channel + // with; applied to the committed manifest with the default channel's + // record, it must change nothing. A `@deepseek-ai/*` package with no rule + // makes it throw, naming the file the rule goes in. + expect(DEFAULT_CHANNEL).toBe("alpha"); + expect(channelManifest(manifestText(), CHANNELS[DEFAULT_CHANNEL])).toBe( + manifestText() + ); + for (const record of Object.values(CHANNELS)) { + for (const [name] of harnessPins()) { + expect(pinFor(name, record), name).toMatch(EXACT); + } + } + }); + + it("the pins, of both channels, are on a channel the peer range admits", () => { // The pin is whatever version the host runs; the range is the surface the // code claims. A pin outside its own range would mean the code was compiled - // against a version it does not admit — the two have to agree even as the - // host moves between channels. + // against a version it does not admit — the two have to agree for the + // committed pins and for every channel's record. const peers = manifest().peerDependencies ?? {}; for (const [name, spec] of harnessPins()) { const peer = peers[name]; @@ -76,6 +199,19 @@ describe("harness channel alignment", () => { `${name}@${spec} is pinned outside its own peer range ${peer}` ).toBe(true); } + const peered = Object.keys(peers).filter((name) => + name.startsWith("@deepseek-ai/") + ); + expect(peered.length).toBeGreaterThan(0); + for (const [channel, record] of Object.entries(CHANNELS)) { + for (const name of peered) { + const pin = pinFor(name, record) ?? ""; + expect( + semver.satisfies(pin, peers[name] ?? ""), + `${channel}: ${name}@${pin} is outside the peer range ${peers[name]}` + ).toBe(true); + } + } }); it("the typert peer range covers BOTH channels of every supported line", () => { @@ -96,17 +232,22 @@ describe("harness channel alignment", () => { manifest().peerDependencies?.["@deepseek-ai/dsh-typert-protocol"]; expect(peer).toBeDefined(); + // The published builds the code is verified on, plus both channels' + // records by construction, so a host bump outside the range fails here. const admitted = [ - // 0.2.1 line, both channels, and the stable release both channels lead to. - "0.2.1-alpha.1", - "0.2.1-alpha.5", - "0.2.1-rc.1", - "0.2.1-rc.5", - "0.2.1", - // 0.2.2 line, both channels, and its stable release. - "0.2.2-alpha.2", - "0.2.2-rc.1", - "0.2.2", + ...new Set([ + // 0.2.1 line, both channels, and the stable release both lead to. + "0.2.1-alpha.1", + "0.2.1-alpha.5", + "0.2.1-rc.1", + "0.2.1-rc.5", + "0.2.1", + // 0.2.2 line, both channels, and its stable release. + "0.2.2-alpha.2", + "0.2.2-rc.1", + "0.2.2", + ...Object.values(CHANNELS).map((record) => record.dsh), + ]), ]; for (const version of admitted) { expect( @@ -125,4 +266,242 @@ describe("harness channel alignment", () => { ).toBe(false); } }); + + it("declares no pi range narrower than the union of the channels' ranges, and pi at runtime not at all", () => { + // The bridge brings pi; the plugin declares none at runtime (the Google + // bridge loads the host's copy). The suite imports pi directly, so it is a + // devDependency, at the union of both channels' ranges, which is what + // lets each channel's lockfile pin the version that channel resolves. + const declared = manifest(); + expect(declared.dependencies?.[PI]).toBeUndefined(); + expect(declared.peerDependencies?.[PI]).toBeUndefined(); + expect(declared.devDependencies?.[PI]).toBe(PI_UNION_RANGE); + for (const [channel, record] of Object.entries(CHANNELS)) { + expect( + semver.subset(record.piRange, PI_UNION_RANGE), + `${channel}'s ${record.piRange} lies inside ${PI_UNION_RANGE}` + ).toBe(true); + // The floor is the range's own floor, which is also the harness's pin. + expect(semver.minVersion(record.piRange)?.version, channel).toBe( + record.piFloor + ); + expect(semver.satisfies(record.pi, record.piRange), channel).toBe(true); + } + // The union is the narrowest range that admits every channel's floor. + expect( + semver.minVersion(PI_UNION_RANGE)?.version, + "the union's floor is the lowest channel floor" + ).toBe( + Object.values(CHANNELS) + .map((record) => record.piFloor) + .toSorted(semver.compare)[0] + ); + }); + + it("carries no pi patch: no patchedDependencies, no pi override, no patch file", () => { + // D14'': an npm install runs pi unpatched, the harness's patch reaches + // users only through the desktop app and touches nothing `src` reads + // (`architecture.test.ts` holds that), and a pnpm patch is keyed to one + // exact version, so carrying it would break every pi bump. + const workspace = readFileSync("pnpm-workspace.yaml", "utf8"); + expect(workspace).toContain("overrides:"); + expect(workspace).not.toMatch(/^patchedDependencies:/mu); + expect(workspace).not.toContain(`"${PI}":`); + const patches = existsSync("patches") ? readdirSync("patches") : []; + expect(patches.filter((file) => file.includes("pi-ai"))).toEqual([]); + }); }); + +describe("harness channel alignment · Dependabot leaves the hosts to a channel bump", () => { + it("ignores every @deepseek-ai package and pi, for every update type", () => { + // A host or pi move is a channel bump (D22''): it moves a record, its + // lockfile and, for alpha, `package.json`'s exact pins together, which a + // Dependabot pull request cannot do, so one would only ever land red and + // take its whole group with it. The nightly drift report notices the move. + interface Rule { + readonly "dependency-name"?: string; + readonly "update-types"?: readonly string[]; + } + interface Update { + readonly ignore?: readonly Rule[]; + readonly "package-ecosystem"?: string; + } + const config = parse(readFileSync(".github/dependabot.yml", "utf8")) as { + readonly updates?: readonly Update[]; + }; + const npm = (config.updates ?? []).filter( + (update) => update["package-ecosystem"] === "npm" + ); + expect(npm).toHaveLength(1); + const rules = new Map( + (npm[0]?.ignore ?? []).map((rule) => [ + rule["dependency-name"], + rule["update-types"], + ]) + ); + // Known answer: the reader found the ignore list (react's major rule). + expect(rules.get("react")).toEqual(["version-update:semver-major"]); + for (const name of ["@deepseek-ai/*", PI]) { + expect(rules.has(name), `${name} is not ignored`).toBe(true); + expect( + rules.get(name), + `${name} is ignored for some update types only` + ).toBeUndefined(); + } + }); +}); + +describe.each(Object.keys(CHANNELS) as Channel[])( + "harness channel alignment · the %s lockfile", + (channel) => { + const record = CHANNELS[channel]; + + it("exists, and pins every direct harness package to the channel's record", () => { + const lock = lockfile(record.lockfile); + // A check that read no entries would otherwise pass by finding nothing. + const harness = [...lock.direct].filter(([name]) => + name.startsWith("@deepseek-ai/") + ); + expect(harness.length).toBe(harnessPins().length); + for (const [name, version] of harness) { + expect(version, name).toBe(pinFor(name, record)); + } + }); + + it("was locked from the manifest and workspace as they stand: every specifier, catalog and override", () => { + // `channels/rc/pnpm-lock.yaml` is the root lockfile with the host + // packages re-resolved, so it carries every NON-host specifier too, and + // CI installs it frozen. A change to any `package.json` dependency or to + // `pnpm-workspace.yaml`'s catalog or overrides regenerates the root + // lockfile only (a Dependabot bump included); without this case the rc + // cell is the first to notice, at its install step. + const fix = + channel === DEFAULT_CHANNEL + ? "pnpm install" + : `node --experimental-strip-types scripts/use-channel.ts ${channel} --relock`; + const documents = (path: string): Record[] => + parseAllDocuments(readFileSync(path, "utf8")).map( + (document) => document.toJS() as Record + ); + const own = documents(record.lockfile); + const root = documents(CHANNELS[DEFAULT_CHANNEL].lockfile); + const project = own.at(-1) ?? {}; + const rootProject = root.at(-1) ?? {}; + const importers = project.importers as + | Record>> + | undefined; + const importer = importers?.["."] ?? {}; + const expected = JSON.parse( + channelManifest(manifestText(), record) + ) as Record | undefined>; + let compared = 0; + for (const section of [ + "dependencies", + "devDependencies", + "optionalDependencies", + ]) { + const locked = Object.fromEntries( + Object.entries(importer[section] ?? {}).map(([name, entry]) => [ + name, + entry.specifier, + ]) + ); + expect( + locked, + `${record.lockfile}'s ${section} differ from package.json as the ${channel} channel installs it; run \`${fix}\`` + ).toStrictEqual(expected[section] ?? {}); + compared += Object.keys(locked).length; + } + // Known answer: the reader found the importer, so equality means + // something (a lockfile it could not read would compare {} to {}). + expect(compared).toBeGreaterThan(20); + expect(project.catalogs, "catalogs").toBeDefined(); + for (const key of [ + "lockfileVersion", + "settings", + "catalogs", + "overrides", + ]) { + expect( + project[key], + `${record.lockfile}'s ${key} differ from the root lockfile's; run \`${fix}\`` + ).toStrictEqual(rootProject[key]); + } + // pnpm's own package-manager record, the file's first document. + expect(own.length, record.lockfile).toBe(root.length); + expect( + own[0], + `${record.lockfile}'s package-manager record differs from the root lockfile's; run \`${fix}\`` + ).toStrictEqual(root[0]); + }); + + it("resolves every dsh package in the tree, transitive ones included, to the channel's cluster", () => { + // The declarations cannot see a transitive dependency: a cluster package + // nobody here names (`dsh-brand`, `dsh-util-values`, …) pulled in at a + // second version is exactly the split cluster this file is about. + const lock = lockfile(record.lockfile); + const dsh = lock.resolved.filter(([name]) => + name.startsWith("@deepseek-ai/dsh-") + ); + expect(dsh.length).toBeGreaterThan(0); + expect( + dsh + .filter(([, version]) => version !== record.dsh) + .map(([name, version]) => `${name}@${version}`) + ).toEqual([]); + expect(lock.versionsOf("@deepseek-ai/cordis")).toEqual([record.cordis]); + expect(lock.versionsOf("@deepseek-ai/cordis-plugin-loader")).toEqual([ + record.loader, + ]); + // The plugin's one runtime library the cluster also depends on resolves + // to ONE copy, the channel's: a second copy would leave two of the + // library the Config schema is built with. On rc that copy is the + // HOST's line, not the plugin's published pin: `use-channel.ts` repins + // the plugin's own dependency to the record's `3.18.5`, while the + // published manifest pins the alpha line, which the rc bridge's `~` range + // does not admit. So this cell checks the host's schemastery; an rc npm + // install of the published plugin holds two copies until that pin admits + // both lines (an open owner decision). + expect(lock.versionsOf("@deepseek-ai/schemastery")).toEqual([ + record.schemastery, + ]); + }); + + it("holds no harness package on the other channel's prerelease line", () => { + // The checks above name dsh, cordis, the loader and schemastery; a + // relock that kept the other channel's optional peers (`-include`, + // `-group`, `cosmokit`) would pass them. This reads every + // `@deepseek-ai` version in the file, keys and values alike. + const foreign = foreignTags(channel, CHANNELS); + const versions = [ + ...lockfile(record.lockfile).text.matchAll( + /@deepseek-ai\/[a-z-]+(?:@|'?:\s+)(\d[^\s'()]*)/gu + ), + ].map((match) => match[1] ?? ""); + // Known answer: the reader finds the channel's own cluster. + expect(versions).toContain(record.dsh); + expect( + versions.filter((version) => + foreign.some((tag) => version.includes(`-${tag}.`)) + ) + ).toEqual([]); + }); + + it("pins pi once, unpatched, at the newest version the bridge's range resolves", () => { + const lock = lockfile(record.lockfile); + expect(lock.direct.get(PI)).toBe(record.pi); + expect(lock.versionsOf(PI)).toEqual([record.pi]); + const snapshots = [ + ...lock.snapshots.matchAll(/^ {2}'@earendil-works\/pi-ai@([^']+)':/gmu), + ].map((match) => match[1] ?? ""); + // A snapshot key carries the peer suffix, so a second copy under a + // different peer context counts as a second copy. + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.startsWith(`${record.pi}(`), snapshots[0]).toBe( + true + ); + expect(lock.text).not.toContain("patch_hash"); + expect(lock.text).not.toMatch(/^patchedDependencies:/mu); + }); + } +); diff --git a/test/host-drift.test.ts b/test/host-drift.test.ts new file mode 100644 index 00000000..d3eb4092 --- /dev/null +++ b/test/host-drift.test.ts @@ -0,0 +1,389 @@ +/** + * The nightly host-drift report: the rules (`scripts/host-drift.ts`) with + * known answers, the CLI (`scripts/report-host-drift.ts`) run against a + * loopback registry, and the workflow step that runs it, parsed. + * + * @module test/host-drift + */ +import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +import { + type DriftRecord, + floorOf, + hostDrift, + newestIn, + type Packument, + REGISTRY_PACKAGES, + type RegistryView, +} from "../scripts/host-drift.ts"; +import { type Channel, CHANNELS } from "./support/host-versions.ts"; + +const ROOT = join(import.meta.dirname, ".."); + +/** Run a command to completion without blocking the loopback server. */ +const run = async ( + file: string, + args: readonly string[], + options: { cwd: string; env: NodeJS.ProcessEnv } +): Promise<{ stderr: string; stdout: string }> => + new Promise((resolve, reject) => { + execFile(file, args, options, (error, stdout, stderr) => { + if (error === null) { + resolve({ stderr, stdout }); + } else { + reject(new Error(`${error.message}\n${stderr}`)); + } + }); + }); + +const versions = (...list: string[]): Packument => ({ + versions: Object.fromEntries(list.map((version) => [version, {}])), +}); + +/** + * The registry exactly as the records describe it: each channel's dsh under + * its dist-tag, a bridge at that version declaring the record's ranges, and + * every recorded version published. + */ +const registryOf = ( + records: Readonly> +): RegistryView => { + const all = Object.values(records); + return { + bridge: { + versions: Object.fromEntries( + all.map((record) => [ + record.dsh, + { + dependencies: { + [REGISTRY_PACKAGES.pi]: record.piRange, + [REGISTRY_PACKAGES.schemastery]: `~${record.schemastery}`, + }, + peerDependencies: { + [REGISTRY_PACKAGES.cordis]: `~${record.cordis}`, + [REGISTRY_PACKAGES.loader]: `~${record.loader}`, + }, + }, + ]) + ), + }, + cordis: versions(...all.map((record) => record.cordis)), + dsh: { + "dist-tags": Object.fromEntries( + all.map((record) => [record.distTag, record.dsh]) + ), + }, + loader: versions(...all.map((record) => record.loader)), + pi: versions( + ...all.flatMap((record) => [record.pi, record.piFloor]), + // Never the newest 1.x: a prerelease and the next major. + "1.99.0-beta.1", + "2.0.0" + ), + schemastery: versions(...all.map((record) => record.schemastery)), + }; +}; + +/** Today's alpha cut: dsh moved, the cluster's ranges with it. */ +const ALPHA_3: RegistryView = (() => { + const base = registryOf(CHANNELS); + return { + ...base, + bridge: { + versions: { + ...base.bridge.versions, + "0.2.2-alpha.3": { + dependencies: { + [REGISTRY_PACKAGES.pi]: CHANNELS.alpha.piRange, + [REGISTRY_PACKAGES.schemastery]: "~3.18.6-alpha.2", + }, + peerDependencies: { + [REGISTRY_PACKAGES.cordis]: "~4.0.6-alpha.2", + [REGISTRY_PACKAGES.loader]: "~1.0.7-alpha.2", + }, + }, + }, + }, + cordis: versions(CHANNELS.rc.cordis, "4.0.6-alpha.1", "4.0.6-alpha.2"), + dsh: { + "dist-tags": { alpha: "0.2.2-alpha.3", latest: CHANNELS.rc.dsh }, + }, + loader: versions(CHANNELS.rc.loader, "1.0.7-alpha.1", "1.0.7-alpha.2"), + schemastery: versions( + CHANNELS.rc.schemastery, + "3.18.6-alpha.1", + "3.18.6-alpha.2" + ), + }; +})(); + +describe("host drift · the rules", () => { + it("resolves a range as npm does: the newest admitted, a prerelease only on its own tuple", () => { + const cordis = versions("4.0.5", "4.0.6-alpha.1", "4.0.6-alpha.2", "4.1.0"); + expect(newestIn(cordis, "~4.0.6-alpha.1")).toBe("4.0.6-alpha.2"); + expect(newestIn(cordis, "~4.0.5")).toBe("4.0.5"); + expect( + newestIn(versions("1.3.0", "1.5.0", "1.6.0-beta.1", "2.0.0"), "^1.3.0") + ).toBe("1.5.0"); + expect(newestIn(versions("1.0.0"), "^1.3.0")).toBeUndefined(); + expect(floorOf("^1.3.0")).toBe("1.3.0"); + expect(floorOf("~4.0.6-alpha.1")).toBe("4.0.6-alpha.1"); + expect(floorOf("not a range")).toBeUndefined(); + }); + + it("reports nothing when the registry is what the records say", () => { + expect(hostDrift(CHANNELS, registryOf(CHANNELS))).toStrictEqual([]); + }); + + it("names a channel whose dist-tag moved, alone when nothing else did", () => { + const registry = registryOf(CHANNELS); + const moved: RegistryView = { + ...registry, + bridge: { + versions: { + ...registry.bridge.versions, + "0.2.2-alpha.3": registry.bridge.versions?.[CHANNELS.alpha.dsh] ?? {}, + }, + }, + dsh: { "dist-tags": { alpha: "0.2.2-alpha.3", latest: CHANNELS.rc.dsh } }, + }; + expect(hostDrift(CHANNELS, moved)).toStrictEqual([ + `alpha: @deepseek-ai/dsh@alpha is 0.2.2-alpha.3 on the registry; the record says ${CHANNELS.alpha.dsh}`, + ]); + }); + + it("follows the cluster's ranges, not the packages' own tags: today's alpha cut", () => { + const lines = hostDrift(CHANNELS, ALPHA_3); + expect(lines).toHaveLength(4); + expect(lines[0]).toMatch( + /^alpha: @deepseek-ai\/dsh@alpha is 0\.2\.2-alpha\.3/u + ); + expect(lines).toContain( + `alpha: @deepseek-ai/cordis (~4.0.6-alpha.2) is 4.0.6-alpha.2 on the registry; the record says ${CHANNELS.alpha.cordis}` + ); + expect(lines).toContain( + `alpha: @deepseek-ai/cordis-plugin-loader (~1.0.7-alpha.2) is 1.0.7-alpha.2 on the registry; the record says ${CHANNELS.alpha.loader}` + ); + expect(lines).toContain( + `alpha: @deepseek-ai/schemastery (~3.18.6-alpha.2) is 3.18.6-alpha.2 on the registry; the record says ${CHANNELS.alpha.schemastery}` + ); + // The rc channel's records are current in this view, and stay silent. + expect(lines.filter((line) => line.startsWith("rc:"))).toStrictEqual([]); + }); + + it("reports a moved pi range by the range, its floor and what it resolves", () => { + const registry = registryOf(CHANNELS); + const bridge = registry.bridge.versions?.[CHANNELS.rc.dsh]; + const moved: RegistryView = { + ...registry, + bridge: { + versions: { + ...registry.bridge.versions, + [CHANNELS.rc.dsh]: { + ...bridge, + dependencies: { + ...bridge?.dependencies, + [REGISTRY_PACKAGES.pi]: "^1.7.0", + }, + }, + }, + }, + pi: versions(CHANNELS.rc.pi, "1.7.0", "1.7.2"), + }; + expect(hostDrift({ rc: CHANNELS.rc }, moved)).toStrictEqual([ + `rc: the bridge's pi range is ^1.7.0 on the registry; the record says ${CHANNELS.rc.piRange}`, + `rc: the pi range's floor is 1.7.0 on the registry; the record says ${CHANNELS.rc.piFloor}`, + `rc: the newest pi ^1.7.0 resolves is 1.7.2 on the registry; the record says ${CHANNELS.rc.pi}`, + ]); + }); + + it("reports a newer pi inside the same range", () => { + const registry = registryOf(CHANNELS); + const lines = hostDrift(CHANNELS, { + ...registry, + pi: versions(...Object.keys(registry.pi.versions ?? {}), "1.6.1"), + }); + expect(lines).toHaveLength(2); + for (const channel of ["alpha", "rc"] as Channel[]) { + expect(lines).toContain( + `${channel}: the newest pi ${CHANNELS[channel].piRange} resolves is 1.6.1 on the registry; the record says ${CHANNELS[channel].pi}` + ); + } + }); + + it("never reads 'nothing answered' as current", () => { + expect( + hostDrift(CHANNELS, { + bridge: {}, + cordis: {}, + dsh: {}, + loader: {}, + pi: {}, + schemastery: {}, + }) + ).toStrictEqual([ + "alpha: @deepseek-ai/dsh has no 'alpha' dist-tag; nothing compared", + "rc: @deepseek-ai/dsh has no 'latest' dist-tag; nothing compared", + ]); + const registry = registryOf(CHANNELS); + expect( + hostDrift({ alpha: CHANNELS.alpha }, { ...registry, bridge: {} }) + ).toStrictEqual([ + `alpha: @deepseek-ai/dsh-llm-pi-ai@${CHANNELS.alpha.dsh} is not published; the ranges were not compared`, + ]); + const bridge = registry.bridge.versions?.[CHANNELS.rc.dsh]; + expect( + hostDrift( + { rc: CHANNELS.rc }, + { + ...registry, + bridge: { + versions: { + [CHANNELS.rc.dsh]: { ...bridge, dependencies: {} }, + }, + }, + } + ) + ).toStrictEqual([ + `rc: @deepseek-ai/dsh-llm-pi-ai@${CHANNELS.rc.dsh} declares no @earendil-works/pi-ai`, + `rc: @deepseek-ai/dsh-llm-pi-ai@${CHANNELS.rc.dsh} declares no @deepseek-ai/schemastery`, + ]); + expect( + hostDrift({ rc: CHANNELS.rc }, { ...registry, cordis: {} }) + ).toStrictEqual([ + `rc: no published @deepseek-ai/cordis satisfies ~${CHANNELS.rc.cordis}`, + ]); + }); +}); + +/** A loopback registry serving `packuments` by name, recording each request. */ +const serve = async ( + packuments: Readonly> | number +): Promise<{ asked: string[]; server: Server; url: string }> => { + const asked: string[] = []; + const server = createServer((request, response) => { + const name = decodeURIComponent((request.url ?? "").slice(1)); + asked.push(name); + const body = typeof packuments === "number" ? undefined : packuments[name]; + if (body === undefined) { + response.writeHead(typeof packuments === "number" ? packuments : 404); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const { port } = server.address() as AddressInfo; + return { asked, server, url: `http://127.0.0.1:${port}` }; +}; + +const report = async (url: string) => + run( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(ROOT, "scripts", "report-host-drift.ts"), + "--registry", + url, + ], + { cwd: ROOT, env: { ...process.env, DSH_CHANNEL: "" } } + ); + +describe("host drift · the CLI", () => { + const byName = (view: RegistryView): Record => + Object.fromEntries( + Object.entries(REGISTRY_PACKAGES).map(([role, name]) => [ + name, + view[role as keyof RegistryView], + ]) + ); + + it("prints one warning per drift and exits 0, having asked for every package", async () => { + const { asked, server, url } = await serve(byName(ALPHA_3)); + try { + const { stdout, stderr } = await report(url); + expect(stderr).toBe(""); + const warnings = stdout.split("\n").filter((line) => line !== ""); + expect(warnings).toStrictEqual( + hostDrift(CHANNELS, ALPHA_3).map((line) => `::warning::${line}`) + ); + expect(warnings.length).toBeGreaterThan(0); + expect(asked.toSorted()).toStrictEqual( + Object.values(REGISTRY_PACKAGES).toSorted() + ); + } finally { + server.close(); + } + }); + + it("says current when nothing moved", async () => { + const { server, url } = await serve(byName(registryOf(CHANNELS))); + try { + const { stdout } = await report(url); + expect(stdout).toBe( + `current: alpha dsh@${CHANNELS.alpha.dsh}, pi ${CHANNELS.alpha.pi}; rc dsh@${CHANNELS.rc.dsh}, pi ${CHANNELS.rc.pi}\n` + ); + } finally { + server.close(); + } + }); + + it("warns, and still exits 0, when the registry refuses", async () => { + const { server, url } = await serve(503); + try { + const { stdout } = await report(url); + expect(stdout).toMatch( + /^::warning::host drift not checked: the registry answered 503 for @/u + ); + } finally { + server.close(); + } + }); +}); + +describe("host drift · the CI wiring, parsed", () => { + interface Job { + readonly if?: string; + readonly steps: readonly { readonly run?: string }[]; + } + const { jobs } = parse( + readFileSync(join(ROOT, ".github", "workflows", "ci.yml"), "utf8") + ) as { jobs: Record }; + + /** The job's `if`, evaluated: a disjunction of event comparisons only. */ + const runsOn = (job: Job, event: string): boolean => { + const terms = (job.if ?? "").split("||").map((term) => term.trim()); + return terms.some((term) => { + const match = /^github\.event_name == '([a-z_]+)'$/u.exec(term); + if (match === null) { + throw new Error(`unparsed condition term: ${term}`); + } + return match[1] === event; + }); + }; + + it("runs the report nightly and on dispatch, never on a pull request or a push", () => { + const reporting = Object.entries(jobs).filter(([, job]) => + job.steps.some((step) => + (step.run ?? "").includes("scripts/report-host-drift.ts") + ) + ); + expect(reporting.map(([name]) => name)).toStrictEqual(["host-drift"]); + const [[, job]] = reporting as [[string, Job]]; + expect(runsOn(job, "schedule")).toBe(true); + expect(runsOn(job, "workflow_dispatch")).toBe(true); + expect(runsOn(job, "pull_request")).toBe(false); + expect(runsOn(job, "push")).toBe(false); + }); +}); diff --git a/test/host-tokens.test.ts b/test/host-tokens.test.ts index 1bc7fe2b..c6f82075 100644 --- a/test/host-tokens.test.ts +++ b/test/host-tokens.test.ts @@ -16,6 +16,20 @@ * they never use is either undefined or private to a subsystem the plugin is * not part of. * + * **And every token the settings card names is in that vocabulary**, whether or + * not it carries a fallback: the card and every file it renders that names a + * token are scanned. Which token the mounted card renders for a warning is + * asserted in settings-card-purge and settings-store-inspect. The meter + * (`usage-ui.ts`, `usage-panel.tsx`, `usage-pill.tsx`) is left out because the + * card does not render it. `usage-pill.tsx` would pass the scan; the other two + * would not, for measured reasons rather than broken tokens: each spells the + * `--dsw-alias-state-*` wildcard in a prose comment, which the scan reads as + * the name `--dsw-alias-state-`, and `usage-ui.ts` names `--dsw-radius-full`, + * which the primitives never use and which sits behind a literal `999px` + * fallback — the case the first test allows. Scanning the meter would mean + * re-spelling those two comments and letting a literal fallback through, not + * handling a template: no meter file builds a token name from one. + * * @module test/host-tokens.test */ @@ -59,6 +73,21 @@ const hostVocabulary = (): Set => { return found; }; +/** + * The card files whose tokens are checked against the host's vocabulary. A file + * that names no token (`settings-boolean-field.tsx`) is not listed, since the + * scan treats an empty file as one it failed to read. + */ +const CARD_FILES = [ + "src/settings-card.tsx", + "src/settings-store-inspect.tsx", + "src/settings-usage.tsx", + "src/settings-field-shell.tsx", + "src/settings-choice-field.tsx", +]; + +const TOKEN = /--dsw-[\w-]+/gu; + describe("the host's design tokens", () => { it("never falls back from one token to another", () => { // A token the host does not define resolves to nothing. Where the @@ -101,4 +130,24 @@ describe("the host's design tokens", () => { expect(vocabulary.has("--dsw-alias-state-warn-label")).toBe(true); expect(vocabulary.has("--dsw-alias-label-warning")).toBe(false); }); + + it("every token the settings card names is one the host's primitives use", () => { + const vocabulary = hostVocabulary(); + // A read of a directory that moved finds nothing, and "nothing" must not + // read as "the token is absent". + expect(vocabulary.size).toBeGreaterThan(0); + + for (const file of CARD_FILES) { + const named = new Set( + [ + ...readFileSync(join(process.cwd(), file), "utf8").matchAll(TOKEN), + ].map((match) => match[0]) + ); + // A file that names no token at all is a scan that read nothing. + expect(named.size, file).toBeGreaterThan(0); + for (const token of named) { + expect(vocabulary.has(token), `${file} names ${token}`).toBe(true); + } + } + }); }); diff --git a/test/host-versions.test.ts b/test/host-versions.test.ts new file mode 100644 index 00000000..4dbe2631 --- /dev/null +++ b/test/host-versions.test.ts @@ -0,0 +1,216 @@ +/** + * The install under test is one of the hosts the plugin supports: the + * channel's `@deepseek-ai` cluster, with pi unpatched at the version an npm + * install of that channel resolves, or at the range's floor on a floor install + * (`test/support/host-versions.ts`, `scripts/use-channel.ts`). + * + * The cases check the INSTALLED tree against the SELECTED record rather than + * the declarations alone: a pin that `package.json` states but a stray range + * defeats would leave a second pi in `node_modules`, and the suite would + * quietly test that one. What each reads: + * + * - the installed tree — the pi `loadHostPi` resolves and its identity with the + * root import, and the `package.json` versions under `node_modules`; + * - the published ranges — this plugin's `peerDependencies` and the installed + * wrapper's own pi range, each checked against the versions installed. + * + * Each committed lockfile is held to its own record in + * `test/harness-channel.test.ts`, whichever channel is installed. + * + * @module test/host-versions + */ +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; +import semver from "semver"; +import { afterAll, describe, expect, it } from "vitest"; + +import { + CHANNELS, + DEFAULT_CHANNEL, + installedLockHash, + MARKER, + selectedHost, +} from "./support/host-versions.ts"; +import { loadHostPi } from "./support/src.ts"; + +const ROOT = join(import.meta.dirname, ".."); +const HOST = selectedHost(process.env, ROOT); +/** How a failure names the install it expected, so it says what to switch. */ +const EXPECTED = `${HOST.channel}${HOST.floor ? " --floor" : ""} (from ${HOST.source}; switch with scripts/use-channel.ts)`; + +const versionOf = (dir: string): string => { + const manifest = JSON.parse( + readFileSync(join(dir, "package.json"), "utf8") + ) as { version?: string }; + return manifest.version ?? ""; +}; + +const installed = (name: string): string => + versionOf(join(ROOT, "node_modules", name)); + +/** The installed pi's package root, through the root symlink. */ +const piRoot = (): string => + realpathSync(join(ROOT, "node_modules", "@earendil-works", "pi-ai")); + +interface Manifest { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} + +describe(`host install · ${EXPECTED}`, () => { + it("the pi the bridge loads at runtime is the channel's", async () => { + // `loadHostPi` resolves the wrapper and walks up from its real location to + // the first pi it finds — the copy the wrapper itself would load. + const pi = await loadHostPi(); + expect(pi).toBeDefined(); + expect(pi?.version, EXPECTED).toBe(HOST.piInstalled); + }); + + it("the root pi the tests import is that same copy", async () => { + // `loadHostPi` hands pi's function through untouched, so identity proves + // one module instance — the tests and the bridge read one file. + const pi = await loadHostPi(); + expect(pi?.getBuiltinModels).toBe(getBuiltinModels); + expect(versionOf(piRoot()), EXPECTED).toBe(HOST.piInstalled); + // The link the wrapper's own lookup reaches (the walk `installedPiRoot` + // takes from its real location) is the root's copy too. + const wrapper = realpathSync( + join(ROOT, "node_modules", "@deepseek-ai", "dsh-llm-pi-ai") + ); + expect( + realpathSync(join(wrapper, "..", "..", "@earendil-works", "pi-ai")) + ).toBe(piRoot()); + }); + + it("the installed cluster is the channel's, inside this plugin's peer ranges", () => { + // Every `@deepseek-ai/dsh-*` this repo declares, read from the manifest so + // a new cluster package is covered without editing this list. + const manifest = JSON.parse( + readFileSync(join(ROOT, "package.json"), "utf8") + ) as Manifest; + const cluster = Object.keys({ + ...manifest.dependencies, + ...manifest.devDependencies, + }).filter((name) => name.startsWith("@deepseek-ai/dsh-")); + expect(cluster.length).toBeGreaterThan(0); + for (const name of cluster) { + expect(installed(name), `${name}, ${EXPECTED}`).toBe(HOST.dsh); + } + expect(installed("@deepseek-ai/cordis"), EXPECTED).toBe(HOST.cordis); + expect(installed("@deepseek-ai/cordis-plugin-loader"), EXPECTED).toBe( + HOST.loader + ); + expect(installed("@deepseek-ai/schemastery"), EXPECTED).toBe( + HOST.schemastery + ); + // The ranges this plugin publishes admit the host it is tested on: a bump + // that moves the install outside a peer range would otherwise ship as an + // install error on exactly the host users run. + const peers = Object.entries(manifest.peerDependencies ?? {}); + expect(peers.length).toBeGreaterThan(0); + for (const [name, range] of peers) { + const version = installed(name); + expect( + semver.satisfies(version, range), + `${name}@${version} vs ${range}` + ).toBe(true); + } + }); + + it("the installed bridge declares the channel's pi range, and the installed pi is a legal resolution of it", () => { + const wrapper = JSON.parse( + readFileSync( + join(ROOT, "node_modules/@deepseek-ai/dsh-llm-pi-ai/package.json"), + "utf8" + ) + ) as Manifest; + // Either declaration kind, and one must exist: semver reads an empty range + // as `*`, so a missing declaration would otherwise pass without a check. + const range = { + ...wrapper.peerDependencies, + ...wrapper.dependencies, + }["@earendil-works/pi-ai"]; + expect( + range, + "dsh-llm-pi-ai no longer declares @earendil-works/pi-ai (dependencies or peerDependencies)" + ).toBeTruthy(); + expect(range, EXPECTED).toBe(HOST.piRange); + expect(semver.satisfies(HOST.piInstalled, range ?? ""), range).toBe(true); + }); +}); + +describe("host install · which record is selected", () => { + // Driven on a scratch tree, so every branch of the selection is exercised + // rather than only the one this machine's install takes. + const scratch = mkdtempSync(join(tmpdir(), "host-select-")); + afterAll(() => { + rmSync(scratch, { force: true, recursive: true }); + }); + mkdirSync(join(scratch, "node_modules", ".pnpm"), { recursive: true }); + writeFileSync(join(scratch, "node_modules", ".pnpm", "lock.yaml"), "rc\n"); + const hash = installedLockHash(scratch) ?? ""; + + it("defaults to the root lockfile's channel with no marker and no environment", () => { + rmSync(join(scratch, MARKER), { force: true }); + const host = selectedHost({}, scratch); + expect([host.channel, host.floor, host.source]).toEqual([ + DEFAULT_CHANNEL, + false, + "default", + ]); + expect(host.piInstalled).toBe(CHANNELS[DEFAULT_CHANNEL].pi); + }); + + it("follows the marker while the install it describes is still on disk", () => { + writeFileSync( + join(scratch, MARKER), + JSON.stringify({ channel: "rc", floor: true, installed: hash }) + ); + const host = selectedHost({}, scratch); + expect([host.channel, host.floor, host.source]).toEqual([ + "rc", + true, + "marker", + ]); + expect(host.piInstalled).toBe(CHANNELS.rc.piFloor); + }); + + it("ignores a marker a later install has outdated", () => { + writeFileSync( + join(scratch, MARKER), + JSON.stringify({ channel: "rc", floor: false, installed: "0".repeat(64) }) + ); + expect(selectedHost({}, scratch).source).toBe("default"); + }); + + it("lets DSH_CHANNEL and DSH_PI override the marker, and refuses an unknown channel", () => { + writeFileSync( + join(scratch, MARKER), + JSON.stringify({ channel: "rc", floor: true, installed: hash }) + ); + const host = selectedHost({ DSH_CHANNEL: "alpha" }, scratch); + expect([host.channel, host.floor, host.source]).toEqual([ + "alpha", + false, + "env", + ]); + expect( + selectedHost({ DSH_CHANNEL: "alpha", DSH_PI: "floor" }, scratch) + .piInstalled + ).toBe(CHANNELS.alpha.piFloor); + expect(() => selectedHost({ DSH_CHANNEL: "beta" }, scratch)).toThrow( + /DSH_CHANNEL=beta names no channel/u + ); + }); +}); diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 0d418fb3..973ad1e3 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -13,12 +13,15 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - apply, type CordisContext, - getSessionUsage, - clearSessionUsageStore, RESPONSES_ROUTE, -} from "../src/index.ts"; + apply, + clearSessionUsageStore, + findModelSpecOn, + getSessionUsage, + mountInternalRoutes, + resolveConfig, +} from "./support/src.ts"; import { collectUnknown, createMockStream, @@ -259,6 +262,73 @@ describe("apply (plugin lifecycle)", () => { expect(warned.join(" ")).toContain("neither ctx.plugin nor ctx.provide"); }); + it("answers discovery from the LATEST activation's settings after a reload", async () => { + // The host refuses a namespace that is still held, the way cordis does. The + // earlier shape re-registered on every apply, swallowed that refusal, and so + // kept answering with the first activation's settings for the process's life. + const discover = new Map Promise>(); + const ctx = { + effect: () => {}, + llm: { + registerModelDiscovery: (ns: string, fn: () => Promise) => { + if (discover.has(ns)) { + throw new Error(`DUPLICATE_DISCOVERY: ${ns}`); + } + discover.set(ns, fn); + return () => discover.delete(ns); + }, + }, + on: () => {}, + } as unknown as CordisContext; + const contextOf = async (id: string): Promise => { + const rows = (await discover.get("opencode")!()) as { + id: string; + contextWindow: number; + }[]; + return rows.find((row) => row.id === id)?.contextWindow; + }; + + apply(ctx, { maxContextWindow: 262_144 }); + expect(await contextOf("claude-sonnet-4-5")).toBe(262_144); + + apply(ctx, { maxContextWindow: 65_536 }); + expect(await contextOf("claude-sonnet-4-5")).toBe(65_536); + + // Turning the feature off on a reload empties the answer rather than + // leaving the old catalog published. + apply(ctx, { enrichModels: false }); + expect(await discover.get("opencode")!()).toEqual([]); + expect([...discover.keys()].toSorted()).toEqual( + ["dsh-opencode-patch", "opencode", "opencode-go"].toSorted() + ); + }); + + it("registers each discovery namespace on its own, and says which one failed", () => { + // One `try` around all three meant a refusal of the first skipped the rest. + const registered: string[] = []; + const warn = vi.fn(); + const ctx = { + effect: () => {}, + llm: { + registerModelDiscovery: (ns: string) => { + if (ns === "dsh-opencode-patch") { + throw new Error("refused"); + } + registered.push(ns); + }, + }, + logger: { info: () => {}, warn }, + on: () => {}, + } as unknown as CordisContext; + + apply(ctx); + expect(registered.toSorted()).toEqual(["opencode", "opencode-go"]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("model discovery for dsh-opencode-patch"), + expect.any(Error) + ); + }); + it("registers no model discovery when enrichModels is off", () => { const registered: string[] = []; const ctx = { @@ -784,6 +854,58 @@ describe("apply (plugin lifecycle)", () => { expect(recorded?.costUsd).toBeGreaterThan(0); }); + it("prices a turn at the rate of the plane its route belongs to", async () => { + // The planes price some shared ids differently. A Go-first lookup billed + // every Zen turn on those at the Go rate, so each plane is checked here + // against its own catalog row rather than against a hardcoded number. + clearSessionUsageStore(); + let streamHandler: + | ((options: unknown, next: () => unknown) => unknown) + | undefined; + const ctx: CordisContext = { + effect: () => {}, + on: ( + _event: string, + handler: (options: unknown, next: () => unknown) => unknown + ) => { + streamHandler = handler; + }, + }; + apply(ctx); + const model = "deepseek-v4-pro"; + const go = findModelSpecOn("go", model)?.cost?.input; + const zen = findModelSpecOn("zen", model)?.cost?.input; + // The case only means something while the planes disagree. + expect(go).toBeTypeOf("number"); + expect(zen).toBeTypeOf("number"); + expect(go).not.toBe(zen); + + const runTurn = async (provider: string, sessionId: string) => { + const down = async function* down() { + yield { + type: "usage", + usage: { + inputTokens: 1_000_000, + outputTokens: 0, + totalTokens: 1_000_000, + }, + }; + }; + const result: unknown = streamHandler?.( + { model, provider, sessionId }, + () => down() + ); + if (!isAsyncIterableLike(result)) { + throw new Error("expected async iterable downstream"); + } + await collectUnknown(result); + return getSessionUsage(sessionId)?.costUsd; + }; + + expect(await runTurn("opencode-go", "plane-go")).toBeCloseTo(go ?? 0, 6); + expect(await runTurn("opencode", "plane-zen")).toBeCloseTo(zen ?? 0, 6); + }); + it("ignores non-opencode providers and invalid session IDs", () => { let streamHandler: | ((options: unknown, next: () => unknown) => unknown) @@ -1137,3 +1259,59 @@ describe("apply (plugin lifecycle)", () => { expect(await collectUnknown(result)).toEqual(["subagent-chunk"]); }); }); + +describe("mountInternalRoutes", () => { + const config = resolveConfig({}); + const settle = async (): Promise => { + await new Promise((resolve) => { + setImmediate(resolve); + }); + }; + + it("contains a rejected mount instead of leaking an unhandled rejection", async () => { + // A `void` IIFE with no catch turned any bridge failure into an unhandled + // rejection, which Node's default mode answers by exiting the process. + const warn = vi.fn(); + const ctx = { logger: { warn } } as unknown as CordisContext; + const release = mountInternalRoutes(ctx, config, () => + Promise.reject(new Error("DUPLICATE_ADAPTER")) + ); + await settle(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("internal routes did not mount"), + expect.objectContaining({ message: "DUPLICATE_ADAPTER" }) + ); + expect(() => { + release(); + }).not.toThrow(); + }); + + it("withdraws a mount that settles after its release", async () => { + let resolveMount: (stop: () => void) => void = () => {}; + const stop = vi.fn<() => void>(); + const release = mountInternalRoutes( + {} as CordisContext, + config, + () => + new Promise((resolve) => { + resolveMount = resolve; + }) + ); + release(); + resolveMount(stop); + await settle(); + expect(stop).toHaveBeenCalledTimes(1); + }); + + it("withdraws a settled mount once, however often it is released", async () => { + const stop = vi.fn(); + const release = mountInternalRoutes({} as CordisContext, config, () => + Promise.resolve(stop) + ); + await settle(); + expect(stop).not.toHaveBeenCalled(); + release(); + release(); + expect(stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/manifest.test.ts b/test/manifest.test.ts index 7c8b284d..73c590d0 100644 --- a/test/manifest.test.ts +++ b/test/manifest.test.ts @@ -11,7 +11,14 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_PROVIDERS, name as PLUGIN_NAME } from "../src/index.ts"; +import { + CARD_FIELDS, + Config, + CONFIG_ONLY_FIELDS, + DEFAULT_PROVIDERS, + name as PLUGIN_NAME, + NS, +} from "./support/src.ts"; import { isRecord } from "./test-helpers.ts"; afterEach(() => { @@ -45,15 +52,8 @@ describe("bundle manifest consistency", () => { it("keeps the settings namespace equal to the cordis row id", async () => { const patch = await readText("cordis.patch.yml"); expect(patch).toContain("id: dsh-opencode-patch"); - // Read the NS constant textually: importing settings-page.tsx would - // drag the React + ui-primitives runtime chain (whose own deps are - // incomplete for node) into a hermetic suite. - const page = await readText("src/settings-page.tsx"); - const ns = /^export const NS = "([^"]+)";/m.exec(page); - if (ns === null || ns[1] === undefined) { - throw new Error("NS constant not found in src/settings-page.tsx"); - } - expect(ns[1]).toBe("dsh-opencode-patch"); + // The constant the client bundle ships, not its spelling in the source. + expect(NS).toBe("dsh-opencode-patch"); }); it("claims every route the layer mounts, in the shipped row", async () => { @@ -107,3 +107,94 @@ describe("bundle manifest consistency", () => { expect(banner[1]).toBe(pkgRaw.name); }); }); + +/** + * The keys `cordis.patch.yml` documents in its header and sets in its row. + * + * The header lists one key per line as `# key: type description`, with + * continuation lines indented deeper, so a key is exactly three spaces after + * the `#`. The card list runs from the line naming `CARD_FIELDS` to the one + * naming `CONFIG_ONLY_FIELDS`, the config-only list from there to the row. The + * row's keys sit eight spaces in under `config:`; its list items sit ten in and + * start with `-`, so they never read as keys. + */ +const manifestKeys = ( + text: string +): { card: string[]; configOnly: string[]; set: string[] } => { + const lines = text.split("\n"); + const insert = lines.findIndex((line) => line.startsWith("- insert:")); + const cardStart = lines.findIndex((line) => line.includes("CARD_FIELDS")); + const configOnlyStart = lines.findIndex((line) => + line.includes("CONFIG_ONLY_FIELDS") + ); + const configStart = lines.findIndex((line) => /^ +config:\s*$/u.test(line)); + if ( + insert === -1 || + cardStart === -1 || + configOnlyStart <= cardStart || + insert <= configOnlyStart || + configStart <= insert + ) { + throw new Error( + "cordis.patch.yml no longer has the layout this test reads" + ); + } + const keysOf = (slice: string[], pattern: RegExp): string[] => + slice + .map((line) => pattern.exec(line)?.[1]) + .filter((key): key is string => key !== undefined); + const documented = /^# {3}([A-Za-z]\w*):/u; + return { + card: keysOf(lines.slice(cardStart, configOnlyStart), documented), + configOnly: keysOf(lines.slice(configOnlyStart, insert), documented), + set: keysOf(lines.slice(configStart + 1), /^ {8}([A-Za-z]\w*):/u), + }; +}; + +describe("cordis.patch.yml agrees with the schema and the field registry", () => { + const root = path.dirname(import.meta.dirname); + const read = async () => + manifestKeys(await readFile(path.join(root, "cordis.patch.yml"), "utf-8")); + // `Config.dict` is the schema's own field table; `Config({})` would omit + // every key with neither a default nor `.volatile()`. + const schemaKeys = Object.keys(Config.dict ?? {}); + + it("documents and sets only keys the schema has", async () => { + const { card, configOnly, set } = await read(); + // Known answers, so a layout change fails loudly rather than vacuously. + expect(schemaKeys).toContain("debugFile"); + expect(set).toContain("providers"); + expect(card).toContain("injectUserAgent"); + + const unknown = [...card, ...configOnly, ...set].filter( + (key) => !schemaKeys.includes(key) + ); + expect(unknown).toEqual([]); + }); + + it("documents exactly the card fields and exactly the config-only fields", async () => { + const { card, configOnly } = await read(); + expect(card).toContain("injectUserAgent"); + expect(configOnly).toContain("providers"); + + expect(card.toSorted()).toEqual( + CARD_FIELDS.map((entry) => entry.field).toSorted() + ); + expect(configOnly.toSorted()).toEqual( + CONFIG_ONLY_FIELDS.map((entry) => entry.field).toSorted() + ); + }); + + it("documents every schema key exactly once", async () => { + const { card, configOnly } = await read(); + const documented = [...card, ...configOnly]; + expect(new Set(documented).size).toBe(documented.length); + expect(documented.toSorted()).toEqual(schemaKeys.toSorted()); + }); + + it("sets each row key at most once", async () => { + const { set } = await read(); + expect(set).toContain("providers"); + expect(new Set(set).size).toBe(set.length); + }); +}); diff --git a/test/mock/gateway-contract.mock.ts b/test/mock/gateway-contract.mock.ts index 8b8d0c43..09bd88c9 100644 --- a/test/mock/gateway-contract.mock.ts +++ b/test/mock/gateway-contract.mock.ts @@ -16,11 +16,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { + DEFAULT_USAGE_BASE_URL, + LEGACY_PLANE_API, OPENCODE_FAMILY_API, OPENCODE_PLANE_API, -} from "../../src/catalog-data.ts"; -import { DEFAULT_USAGE_BASE_URL } from "../../src/config.ts"; -import { LEGACY_PLANE_API } from "../../src/pi-provider.ts"; +} from "../support/src.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { catalogFor, firstSpecFor, MOCK_SESSION, ZEN_KEY } from "./helpers.ts"; import type { GatewayRequest } from "./opencode-gateway.ts"; diff --git a/test/mock/helpers.ts b/test/mock/helpers.ts index 7226cb3a..b11b3146 100644 --- a/test/mock/helpers.ts +++ b/test/mock/helpers.ts @@ -26,16 +26,14 @@ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completio import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; import { + type ActiveTurnState, + type FetchPatchHooks, OPENCODE_GO_CATALOG, OPENCODE_PLANE_API, OPENCODE_ZEN_CATALOG, -} from "../../src/catalog-data.ts"; -import type { FetchPatchHooks } from "../../src/fetch-patch.ts"; -import { - patchFetch, - type ActiveTurnState, type ResolvedPluginConfig, -} from "../../src/index.ts"; + patchFetch, +} from "../support/src.ts"; import type { GatewayKind } from "./opencode-gateway.ts"; export { MOCK_TEXT } from "./opencode-gateway.ts"; diff --git a/test/mock/opencode-gateway.ts b/test/mock/opencode-gateway.ts index f0e5c049..bcb1fe09 100644 --- a/test/mock/opencode-gateway.ts +++ b/test/mock/opencode-gateway.ts @@ -15,8 +15,8 @@ import { OPENCODE_GO_CATALOG, OPENCODE_PLANE_API, OPENCODE_ZEN_CATALOG, -} from "../../src/catalog-data.ts"; -import { isRecord } from "../../src/utils/guards.ts"; + isRecord, +} from "../support/src.ts"; import { anthropicStream, anthropicUnary, diff --git a/test/mock/protocol-compat.mock.ts b/test/mock/protocol-compat.mock.ts index 650daa90..6880d3fb 100644 --- a/test/mock/protocol-compat.mock.ts +++ b/test/mock/protocol-compat.mock.ts @@ -32,12 +32,12 @@ import { } from "vitest"; import { - clearCapturedApiKeys, OPENCODE_UA, - resolveConfig, SESSION_HEADER, -} from "../../src/index.ts"; -import { piModelFor } from "../../src/pi-provider.ts"; + clearCapturedApiKeys, + piModelFor, + resolveConfig, +} from "../support/src.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { collectText, diff --git a/test/mock/protocols/models-catalog.protocol.ts b/test/mock/protocols/models-catalog.protocol.ts index 3670e5f3..a97f4bdd 100644 --- a/test/mock/protocols/models-catalog.protocol.ts +++ b/test/mock/protocols/models-catalog.protocol.ts @@ -12,7 +12,7 @@ import { OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, -} from "../../../src/catalog-data.ts"; +} from "../../support/src.ts"; export const CATALOG_PROTOCOL = "models-catalog" as const; export const CATALOG_KIND = "models" as const; diff --git a/test/mock/request-shapes.mock.ts b/test/mock/request-shapes.mock.ts index e8cbc1d8..65d299de 100644 --- a/test/mock/request-shapes.mock.ts +++ b/test/mock/request-shapes.mock.ts @@ -25,19 +25,19 @@ import { vi, } from "vitest"; -import type { FetchPatchHooks } from "../../src/fetch-patch.ts"; import { - clearCapturedApiKeys, + type ActiveTurnState, + type FetchPatchHooks, OPENCODE_UA, PARENT_SESSION_ALT_HEADER, PARENT_SESSION_HEADER, - patchFetch, - resolveConfig, + type ResolvedPluginConfig, SESSION_AFFINITY_HEADER, SESSION_HEADER, - type ActiveTurnState, - type ResolvedPluginConfig, -} from "../../src/index.ts"; + clearCapturedApiKeys, + patchFetch, + resolveConfig, +} from "../support/src.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { firstSpecFor, diff --git a/test/mock/testing-kit.ts b/test/mock/testing-kit.ts index 7aa4712d..3635432c 100644 --- a/test/mock/testing-kit.ts +++ b/test/mock/testing-kit.ts @@ -17,19 +17,17 @@ import os from "node:os"; import nodePath from "node:path"; import { DatabaseSync } from "node:sqlite"; -import type { FetchPatchHooks } from "../../src/fetch-patch.ts"; import { - patchFetch, - resolveConfig, type ActiveTurnState, - type PluginConfig, -} from "../../src/index.ts"; -import { + type FetchPatchHooks, OPENCODE_UA, PARENT_SESSION_ALT_HEADER, PARENT_SESSION_HEADER, + type PluginConfig, SESSION_AFFINITY_HEADER, -} from "../../src/session.ts"; + patchFetch, + resolveConfig, +} from "../support/src.ts"; import { startMockGateway, type MockGateway } from "./gateway-server.ts"; import { GO_KEY, diff --git a/test/models-discovery.test.ts b/test/models-discovery.test.ts index 9391972f..2b89be6c 100644 --- a/test/models-discovery.test.ts +++ b/test/models-discovery.test.ts @@ -12,26 +12,24 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + type CordisContext, + PI_THINKING_LEVELS, + RESPONSES_ROUTE, decorateModelDiscovery, decorateModelInfo, + decoratePlaneAdapters, findModelSpecOn, hideResponsesRoute, + isDeprecatedModelOn, isRouteRegistered, + isServableModel, mergeDiscoveredModels, - isDeprecatedModelOn, parseModelsDevCatalog, resolveConfig, resolveDiscoveryProvider, resolveEffectiveContextWindow, - RESPONSES_ROUTE, - type CordisContext, -} from "../src/index.ts"; -import { - decoratePlaneAdapters, - isServableModel, - PI_THINKING_LEVELS, -} from "../src/models-discovery.ts"; -import { thinkingLevelMapFor } from "../src/pi-provider.ts"; + thinkingLevelMapFor, +} from "./support/src.ts"; afterEach(() => { vi.unstubAllGlobals(); @@ -362,6 +360,28 @@ describe("models-discovery · an empty list from a route that declares models", stop?.(); }); + it("passes a provider it does not claim through untouched", async () => { + // A direct `anthropic` or `openai` route is the deployment's, not this + // plugin's. Its ids are not in the OpenCode catalog's routing, and the + // servability filter used to drop them as "unservable". + const foreign = [ + { id: "claude-opus-9", name: "Claude Opus 9", provider: "anthropic" }, + { id: "gpt-7", name: "GPT-7", provider: "openai" }, + ]; + const ctx = { + llm: { + listProviders: () => [], + listConfigurableProviders: () => [], + listModels: async (provider: string) => + foreign.filter((row) => row.provider === provider), + }, + } as unknown as CordisContext; + const stop = hideResponsesRoute(ctx, resolveConfig()); + expect(await ctx.llm?.listModels?.("anthropic")).toEqual([foreign[0]]); + expect(await ctx.llm?.listModels?.("openai")).toEqual([foreign[1]]); + stop?.(); + }); + it("leaves an adapter's own answer alone", async () => { const ctx = { llm: { @@ -1131,6 +1151,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/mount-protocols.test.ts b/test/mount-protocols.test.ts index 2434146a..0321d9b6 100644 --- a/test/mount-protocols.test.ts +++ b/test/mount-protocols.test.ts @@ -1,34 +1,42 @@ import { supportedProtocols } from "@deepseek-ai/dsh-llm-pi-ai"; import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; +import semver from "semver"; import { describe, expect, it } from "vitest"; +import { HOST_VERSIONS } from "./support/host-versions.ts"; import { + GOOGLE_SDK, OPENCODE_GO_CATALOG, OPENCODE_ZEN_CATALOG, PROTOCOL_FOR_SDK, + protocolFor, ROUTE_FOR_PLANE_PROTOCOL, - UNSERVED_SDKS, SELF_MOUNTED_ROUTES, -} from "../src/index.ts"; -import { UNSERVED_PLANE_SDKS } from "../src/responses-routes.ts"; + UNSERVED_PLANE_SDKS, + UNSERVED_SDKS, +} from "./support/src.ts"; /** * The protocols this plugin mounts must be ones the harness's own pi-ai layer - * can build. + * can build, and every SDK the catalog names must map to one of them. * - * `dsh-llm-pi-ai` constructs a hand-declared route from `PROTOCOLS`, and that - * table has three entries. A route naming anything else is refused outright, so - * the plugin's whole routing layer is bounded by this list - and nothing checked - * it: the unit suite mounts against a stand-in that accepts any `api`, and the - * e2e suite talks to the gateway without going through the mount at all. + * Two claims, each against the real package rather than a stand-in: * - * That is how `mistral-conversations` shipped. It is in `COMPAT_GATES`, which - * looks like a protocol registry and is not one, and the real table refuses it. + * - The routes the WRAPPER mounts may only name a protocol in + * `dsh-llm-pi-ai`'s own `PROTOCOLS` table, which refuses anything else + * outright. That is how `mistral-conversations` once shipped: it is in + * `COMPAT_GATES`, which looks like a protocol registry and is not one. + * - Every catalog row resolves, through `protocolFor` (which reads + * `GATEWAY_PROTOCOL_FOR_SDK`), to a protocol pi's exported `api/*` modules + * implement — independent of which models any of pi's own gateway providers + * happen to list. The plugin's own SDK table, `PROTOCOL_FOR_SDK` + * (`src/responses-routes.ts`), is checked against the same modules as a + * second step. In this file "the wrapper's table" means only + * `dsh-llm-pi-ai`'s `PROTOCOLS`. * - * `it.fails` while that route is still declared: it passes only while the gap is - * real, and turns red the moment the mapping is corrected, which is the signal - * to make it a plain assertion. + * The pi and harness versions these run against are named once, in + * `test/support/host-versions.ts`. */ describe("mount · the protocols the harness can build", () => { it("names only protocols dsh-llm-pi-ai supports, for the routes it mounts", () => { @@ -114,63 +122,200 @@ describe("mount · what pi-ai's own gateway providers cover", () => { expect(apis.size).toBeGreaterThan(1); } }); + + /** + * F10, per pi line: whether pi's own `opencode-go` provider lists a model on + * the Google protocol. A characterization of the install under test, keyed + * by the pi it runs (`HOST_VERSIONS.pi`: the channel's newest, or its + * floor), not a requirement. pi 1.1.0, the rc channel's floor and the + * harness's rc pin, has none; pi 1.3.0 added a Go Google row, and 1.5.0 + * keeps it. The gateway's Go catalog carries Google models either way, + * which the plugin routes through its own adapter, so the row describes pi, + * not the plugin. + * + * Rows are pi LINES, not exact versions, so a channel bump that moves pi + * inside a line stays version data (CONTRIBUTING's release step 3): the case + * fails only for a pi outside every row, which is a new major and a + * reviewed change, or for a pi whose provider contradicts its row, which is + * the measured change this row exists to catch. + */ + const GO_HAS_GOOGLE: readonly (readonly [line: string, google: boolean])[] = [ + [">=1.1.0 <1.3.0", false], + [">=1.3.0 <2.0.0", true], + ]; + + const f10RowFor = (pi: string): boolean[] => + GO_HAS_GOOGLE.filter(([line]) => semver.satisfies(pi, line)).map( + ([, google]) => google + ); + + it("F10's rows are one answer per pi line, with known answers", () => { + expect(f10RowFor("1.1.0")).toEqual([false]); + expect(f10RowFor("1.2.9")).toEqual([false]); + expect(f10RowFor("1.3.0")).toEqual([true]); + expect(f10RowFor("1.5.0")).toEqual([true]); + expect(f10RowFor("1.6.2")).toEqual([true]); + // Outside every line: a new major, or a pi below every channel's floor. + expect(f10RowFor("2.0.0")).toEqual([]); + expect(f10RowFor("1.0.9")).toEqual([]); + }); + + it(`characterization (F10): pi ${HOST_VERSIONS.pi}'s opencode-go provider and the Google protocol`, () => { + const rows = f10RowFor(HOST_VERSIONS.pi); + expect( + rows, + `no F10 row for pi ${HOST_VERSIONS.pi}'s line: measure it and add one` + ).toHaveLength(1); + const [expected] = rows; + const go = providers.find((p) => p.id === "opencode-go"); + const apis = new Set((go?.getModels() ?? []).map((model) => model.api)); + expect(apis.has("google-generative-ai")).toBe(expected); + for (const api of [ + "anthropic-messages", + "openai-completions", + "openai-responses", + ]) { + expect(apis.has(api), api).toBe(true); + } + expect(OPENCODE_GO_CATALOG.some((m) => m.provider_npm === GOOGLE_SDK)).toBe( + true + ); + }); }); /** - * Every catalogued model, against the protocols its plane's provider implements. + * Every catalogued model, against the protocols pi's own `api/*` modules + * implement. * * The per-model `api` in our catalog is the vendor's own SDK (`@ai-sdk/…`), and - * `PROTOCOL_FOR_SDK` turns it into a wire protocol. This walks the whole catalog + * `protocolFor` turns it into a wire protocol — `openai-completions` for a row + * that names no SDK, the provider's own default. This walks the whole catalog * rather than a sample, because the failure this catches is one model in a * refreshed catalog naming an SDK nobody has a protocol for - and a picker that * offers it looks exactly like a picker that does not. * - * A model is fine when its plane's pi-ai provider has at least one model on the - * same protocol, since a provider only carries protocols it can speak. Models - * whose SDK is a recorded decision (`UNSERVED_SDKS`) are skipped: that is the - * plugin saying so, not a gap. + * The claim is against pi's exported implementations, resolved from pi itself + * (`@earendil-works/pi-ai/api/.lazy`), not a provider's model list + * and not a table kept here. An earlier version asked each plane's pi provider + * whether it carried a model on the same protocol, which made the answer a + * fact about pi's catalog: pi 1.3.0 listed a Go Gemini model and so passed, + * while pi 1.1.0 (the rc channel's floor) lists none (characterized above, + * F10) although the + * bridge serves those models through the same `google-generative-ai` module. + * Models whose SDK is a recorded decision (`UNSERVED_SDKS`) are skipped: that + * is the plugin saying so, not a gap. Plane-level decisions in + * `UNSERVED_PLANE_SDKS` likewise, so the rows here are the rows the picker + * offers (`isServableModel`, src/models-discovery.ts) and `test/catalog.test.ts` + * walks. */ -describe("routing · every catalogued model has a protocol its plane can serve", () => { - const protocolsOf = (id: string): Set => - new Set( - ( - builtinProviders() - .find((p) => p.id === id) - ?.getModels() ?? [] - ).map((model) => model.api) +describe("routing · every catalog SDK maps to a protocol pi's api/* modules implement", () => { + /** + * Whether pi ships `api/.lazy` with one `*Api` factory whose + * implementation has both stream entry points the adapter delegates to. + */ + const implemented = async (protocol: string): Promise => { + let module: Record; + try { + module = (await import( + /* @vite-ignore */ `@earendil-works/pi-ai/api/${protocol}.lazy` + )) as Record; + } catch { + return false; + } + const factories = Object.entries(module) + .filter(([name]) => name.endsWith("Api")) + .map(([, value]) => value); + const [factory] = factories; + if (factories.length !== 1 || typeof factory !== "function") { + return false; + } + const api = (factory as () => unknown)() as { + stream?: unknown; + streamSimple?: unknown; + }; + return ( + typeof api.stream === "function" && typeof api.streamSimple === "function" ); + }; - const planes = [ - { catalog: OPENCODE_ZEN_CATALOG, id: "opencode" }, - { catalog: OPENCODE_GO_CATALOG, id: "opencode-go" }, - ]; + /** Every catalog row the plugin offers, with the protocol it resolves to. */ + const served = () => + ( + [ + ["opencode", OPENCODE_ZEN_CATALOG], + ["opencode-go", OPENCODE_GO_CATALOG], + ] as const + ).flatMap(([plane, catalog]) => + catalog + .filter( + (model) => + model.provider_npm === undefined || + // The `${plane} + ${sdk}` key is the one `isServableModel` reads + // (the route ids `opencode` / `opencode-go`). The set is empty + // today; an entry that withholds a protocol on every plane then + // turns the "four protocols" constant below red, for review. + !( + UNSERVED_SDKS.has(model.provider_npm) || + UNSERVED_PLANE_SDKS.has(`${plane} + ${model.provider_npm}`) + ) + ) + .map((model) => ({ model, plane, protocol: protocolFor(model) })) + ); - it("names no protocol its plane's provider cannot speak", () => { - const unsupported: string[] = []; - let checked = 0; - for (const { catalog, id } of planes) { - const protocols = protocolsOf(id); - for (const model of catalog) { - const sdk = model.provider_npm; - // No SDK means the provider's own default, which is always servable. - if ( - sdk === undefined || - UNSERVED_SDKS.has(sdk) || - UNSERVED_PLANE_SDKS.has(`${id} + ${sdk}`) - ) { - continue; - } - checked += 1; - const protocol = PROTOCOL_FOR_SDK[sdk]; - if (protocol === undefined || !protocols.has(protocol)) { - unsupported.push( - `${id} ${model.id}: ${sdk} -> ${protocol ?? "no protocol"}` - ); - } - } - } + it("names no SDK whose protocol pi does not implement", async () => { + const rows = served(); // A check that read no models would otherwise pass by finding nothing. - expect(checked).toBeGreaterThan(0); + expect(rows.length).toBeGreaterThan(0); + const protocols = [ + ...new Set( + [ + ...rows.map((row) => row.protocol), + ...Object.values(PROTOCOL_FOR_SDK), + ].filter((name) => name !== undefined) + ), + ]; + const verdicts = new Map( + await Promise.all( + protocols.map(async (name) => [name, await implemented(name)] as const) + ) + ); + const unsupported = rows + .filter( + (row) => + row.protocol === undefined || verdicts.get(row.protocol) !== true + ) + .map( + (row) => + `${row.plane} ${row.model.id}: ${row.model.provider_npm ?? "(default)"} -> ${row.protocol ?? "no protocol"}` + ); expect(unsupported).toEqual([]); + // The plugin's own SDK table (`PROTOCOL_FOR_SDK`, src/responses-routes.ts) + // names nothing pi lacks either. + expect( + Object.values(PROTOCOL_FOR_SDK).filter( + (protocol) => verdicts.get(protocol) !== true + ) + ).toEqual([]); + }); + + it("reaches the four protocols the plugin serves (a constant, labelled as one)", () => { + // The set the catalog resolves to today, including the SDK-less default. + // A fifth protocol is a deliberate change, reviewed here; whether pi + // implements it is the case above, which reads pi rather than this list. + expect( + [...new Set(served().map((row) => row.protocol))].toSorted((a, b) => + (a ?? "").localeCompare(b ?? "") + ) + ).toEqual([ + "anthropic-messages", + "google-generative-ai", + "openai-completions", + "openai-responses", + ]); + }); + + it("answers false for a protocol pi does not ship", async () => { + // The negative control: an unknown module is not read as implemented. + expect(await implemented("no-such-protocol")).toBe(false); }); }); diff --git a/test/no-local-paths.test.ts b/test/no-local-paths.test.ts index 26bfe855..b96215af 100644 --- a/test/no-local-paths.test.ts +++ b/test/no-local-paths.test.ts @@ -19,17 +19,19 @@ import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; -import { CARD_FIELDS } from "../src/settings-fields.ts"; +import { CARD_FIELDS } from "./support/src.ts"; const ROOT = new URL("..", import.meta.url).pathname; /** Directories a third party can read: the tree, plus the build output. */ -const SCANNED = ["src", "test", "docs", "scripts", "lib"]; +const SCANNED = ["src", "test", "docs", "scripts", "lib", "channels"]; const SCANNED_FILES = [ "AGENTS.md", + "CONTRIBUTING.md", "README.md", "package.json", "cordis.patch.yml", + "pnpm-workspace.yaml", ]; /** A home prefix followed by a name that is not the rule's placeholder. */ 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-paths.test.ts b/test/opencode-paths.test.ts index 7f09b52b..63e0a4c6 100644 --- a/test/opencode-paths.test.ts +++ b/test/opencode-paths.test.ts @@ -18,7 +18,7 @@ import { opencodeDatabaseFile, opencodeDatabasePath, opencodeRoots, -} from "../src/opencode-paths.ts"; +} from "./support/src.ts"; const HOME = "/home/you"; // the placeholder the hard-rule test prescribes const environment = ( diff --git a/test/opencode-store-service.test.ts b/test/opencode-store-service.test.ts index a8e10413..ea2e174d 100644 --- a/test/opencode-store-service.test.ts +++ b/test/opencode-store-service.test.ts @@ -16,7 +16,7 @@ import { DatabaseSync } from "node:sqlite"; import { afterEach, describe, expect, it } from "vitest"; -import { OpencodeStoreService } from "../src/opencode-store-service.ts"; +import { OpencodeStoreService } from "./support/src.ts"; import { createMockContext } from "./test-helpers.ts"; const temporary: string[] = []; diff --git a/test/opencode-store.test.ts b/test/opencode-store.test.ts index 551add3d..aa5198e0 100644 --- a/test/opencode-store.test.ts +++ b/test/opencode-store.test.ts @@ -23,7 +23,7 @@ import { inspectOpencodeStore, readOpencodeCredentials, summarizeCredential, -} from "../src/opencode-store.ts"; +} from "./support/src.ts"; const temporary: string[] = []; @@ -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/package-checks.test.ts b/test/package-checks.test.ts new file mode 100644 index 00000000..df07e684 --- /dev/null +++ b/test/package-checks.test.ts @@ -0,0 +1,166 @@ +/** + * The package's publish shape, checked against the files that define it. + * + * Two promises live in two files each, and nothing held them together: + * + * - `exports` names what a consumer may resolve, and `files` names what the + * tarball carries. An export whose target is not packed resolves in this + * repository and fails with ENOENT in every install (`./src/*` did exactly + * that: `src/` was never in `files`). + * - `scripts/publish-scoped.ts` publishes a list of aliases, and the release + * workflow's verify step polls its own list. A name in one and not the other + * is either a publish nobody checks or a check that can never pass. + * + * The functions are pure (`scripts/package-checks.ts`), so the fixtures below + * prove each one answers a case whose answer is known, and the real-tree cases + * run the same functions `scripts/check.ts` runs. + * + * @module test/package-checks.test + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + coveredByFiles, + exportsOutsideFiles, + exportTargets, + publishListMismatch, + releasePendingTargets, +} from "../scripts/package-checks.ts"; +import { SCOPED_TARGETS } from "../scripts/scoped-targets.ts"; + +const ROOT = join(import.meta.dirname, ".."); + +const readPackage = (): { + name: string; + exports?: unknown; + files?: readonly string[]; +} => + JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + name: string; + exports?: unknown; + files?: readonly string[]; + }; + +describe("package checks · exports inside files", () => { + it("flattens an exports map to its targets, conditions included", () => { + expect( + exportTargets({ + ".": { default: "./lib/index.mjs", types: "./lib/index.d.mts" }, + "./x": "./src/x.ts", + }) + ).toEqual(["./lib/index.mjs", "./lib/index.d.mts", "./src/x.ts"]); + expect(exportTargets("./lib/index.mjs")).toEqual(["./lib/index.mjs"]); + expect(exportTargets(null)).toEqual([]); + }); + + it("knows what a files entry covers", () => { + expect(coveredByFiles("./lib/index.mjs", ["lib"])).toBe(true); + expect(coveredByFiles("./library.js", ["lib"])).toBe(false); + expect(coveredByFiles("./package.json", [])).toBe(true); + expect(coveredByFiles("./locale/en.json", ["locale/*.json"])).toBe(true); + expect(coveredByFiles("./locale/*.json", ["locale/*.json"])).toBe(true); + expect(coveredByFiles("./locale/nested/en.json", ["locale/*.json"])).toBe( + false + ); + expect(coveredByFiles("./src/*", ["lib"])).toBe(false); + }); + + it("exportsOutsideFiles names a target the files allowlist does not cover (fixture)", () => { + expect( + exportsOutsideFiles({ + exports: { + ".": { import: "./lib/index.mjs", types: "./lib/index.d.mts" }, + "./x": "./src/x.ts", + }, + files: ["lib"], + }) + ).toEqual(["./src/x.ts"]); + expect( + exportsOutsideFiles({ + exports: { + "./client": { default: "./lib/client.js" }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./locale/en.json": "./locale/en.json", + "./package.json": "./package.json", + }, + files: ["lib", "locale/*.json", "cordis.patch.yml"], + }) + ).toEqual([]); + expect( + exportsOutsideFiles({ exports: { "./src/*": "./src/*" }, files: ["lib"] }) + ).toEqual(["./src/*"]); + }); + + it("every exports target of this package is inside files (real package.json)", () => { + const pkg = readPackage(); + // Non-vacuous: the real map has targets to check. + expect(exportTargets(pkg.exports).length).toBeGreaterThan(3); + expect(exportsOutsideFiles(pkg)).toEqual([]); + }); +}); + +describe("package checks · the publish list and the verify list agree", () => { + it("releasePendingTargets reads the verify line and reports its absence (fixture)", () => { + expect( + releasePendingTargets(' pending="a@$VER b@$VER"\n') + ).toEqual(["a", "b"]); + expect(releasePendingTargets("jobs:\n verify:\n")).toBeUndefined(); + // Two lists are ambiguous: reading either one would leave the other + // unchecked, so the reader refuses rather than picking the first. + expect( + releasePendingTargets( + ' pending="x@$VER"\n pending="a@$VER b@$VER"\n' + ) + ).toBeUndefined(); + // The later unquoted reassignment in the real workflow is not a list. + expect( + releasePendingTargets( + ' pending="a@$VER"\n pending=$(echo "$still" | xargs)\n' + ) + ).toEqual(["a"]); + const mismatch = publishListMismatch(["p", "a"], ["p", "a", "b"]); + expect(mismatch).toHaveLength(1); + expect(mismatch[0]).toContain("b"); + expect(publishListMismatch(["p", "a", "c"], ["p", "a"])[0]).toContain("c"); + expect(publishListMismatch(["p", "a"], ["p", "a"])).toEqual([]); + }); + + it("the scoped publish list and release.yml's verify list agree, and neither names the retired wrapper", () => { + const pkg = readPackage(); + const pending = releasePendingTargets( + readFileSync(join(ROOT, ".github", "workflows", "release.yml"), "utf8") + ); + expect(pending).toEqual([pkg.name, ...SCOPED_TARGETS]); + expect(SCOPED_TARGETS).toEqual(["@viztor/dsh-opencode-patch"]); + expect(pending).not.toContain("@viztor/dsh-opencode"); + }); + + it("the gate and the publish script read those lists rather than keeping their own", () => { + // Structural, because running either script from the suite would copy the + // package per target (publish-scoped) or install it (check). The pure + // helpers are exercised above; this pins that the scripts still use them. + // Comment lines dropped line by line: a block-comment regex would eat + // code between a glob such as `src/*` and the next `*/`. + const code = (file: string): string => + readFileSync(join(ROOT, "scripts", file), "utf8") + .split("\n") + .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/u.test(line)) + .join("\n"); + const publish = code("publish-scoped.ts"); + expect(publish).toMatch( + /import \{[^}]*\bSCOPED_TARGETS\b[^}]*\} from "\.\/scoped-targets\.ts";/u + ); + expect( + [...publish.matchAll(/for \(const \w+ of (\w+)\)/gu)].map((m) => m[1]) + ).toContain("SCOPED_TARGETS"); + expect(publish).not.toContain('"@viztor/dsh-opencode"'); + const check = code("check.ts"); + expect(check).toMatch(/\bexportsOutsideFiles\(/u); + expect(check).toMatch(/\bpublishListMismatch\(/u); + expect(check).toMatch(/\breleasePendingTargets\(/u); + }); +}); diff --git a/test/pi-provider.test.ts b/test/pi-provider.test.ts index ac1c7513..11a2a6d1 100644 --- a/test/pi-provider.test.ts +++ b/test/pi-provider.test.ts @@ -5,17 +5,18 @@ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completio import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; import { describe, expect, it } from "vitest"; -import { OPENCODE_PLANE_API } from "../src/catalog-data.ts"; -import type { CatalogModelSpec } from "../src/catalog-data.ts"; -import { ENDPOINT_FAMILIES } from "../src/config-values.ts"; -import { OPENCODE_USER_AGENT } from "../src/identity.ts"; import { BASE_FOR_PROTOCOL, + type CatalogModelSpec, + ENDPOINT_FAMILIES, + OPENCODE_PLANE_API, + OPENCODE_USER_AGENT, baseForProtocol, piModelFor, + planeBaseFor, protocolFor, thinkingLevelMapFor, -} from "../src/pi-provider.ts"; +} from "./support/src.ts"; /** * The headers this test feeds the model. @@ -275,9 +276,9 @@ describe("pi-provider · the tables themselves", () => { } }); - it("resolves each endpoint family to its own base, and auto to the catalog", () => { - // `auto` is the catalog's own address and therefore cannot be wrong about - // which family is current; the other two pin one, which is the whole point + it("resolves each endpoint family to its own base, and auto as modern", () => { + // `auto` is an alias of `modern` (the copy says so, test/endpoint-copy); + // `legacy` pins the older single base per plane, which is the whole point // of the setting — the two families are separately entitled. expect(baseForProtocol("opencode", "openai-completions", "auto")).toBe( OPENCODE_PLANE_API.zen @@ -301,6 +302,19 @@ describe("pi-provider · the tables themselves", () => { ); }); + it("resolves auto exactly as modern on every protocol and plane", () => { + for (const plane of ["opencode", "opencode-go"] as const) { + expect(planeBaseFor(plane, "auto")).toBe(planeBaseFor(plane, "modern")); + const protocols = Object.keys(BASE_FOR_PROTOCOL[plane]); + expect(protocols.length).toBe(4); + for (const protocol of protocols) { + expect(baseForProtocol(plane, protocol, "auto"), protocol).toBe( + baseForProtocol(plane, protocol, "modern") + ); + } + } + }); + it("keeps the Anthropic base one level shorter on every family", () => { for (const family of ENDPOINT_FAMILIES) { for (const plane of ["opencode", "opencode-go"] as const) { @@ -333,7 +347,7 @@ describe("pi-provider · the tables themselves", () => { expect( piModelFor("opencode", model, "openai-completions", {}, "modern").baseUrl ).toBe(OPENCODE_PLANE_API.zen); - // No family named means the catalog's own address, as before. + // No family named means the default, auto, which resolves as modern. expect( piModelFor("opencode", model, "openai-completions", {}).baseUrl ).toBe(OPENCODE_PLANE_API.zen); diff --git a/test/picker-integration.test.ts b/test/picker-integration.test.ts index 83c525ba..73731aaa 100644 --- a/test/picker-integration.test.ts +++ b/test/picker-integration.test.ts @@ -32,11 +32,11 @@ import { describe, expect, it } from "vitest"; import { + type CordisContext, decorateModelInfo, hideResponsesRoute, resolveConfig, - type CordisContext, -} from "../src/index.ts"; +} from "./support/src.ts"; /** What a deployment declared for its parent route. Three ids, no catalog backfill. */ const DECLARED = [ diff --git a/test/plane-divergence.test.ts b/test/plane-divergence.test.ts index c1aac635..300ce887 100644 --- a/test/plane-divergence.test.ts +++ b/test/plane-divergence.test.ts @@ -7,16 +7,19 @@ * real catalogue disagrees about the PRICES today (two models), and agrees * about everything else, so a test built on the real catalogue cannot tell a * plane-aware lookup from a plane-blind one: both pass. That is the gap this - * file closes, using `test/fixtures/catalogs.ts` to build the row. + * file closes, using `test/support/catalogs.ts` to build the row. * * @module test/plane-divergence.test */ import { describe, expect, it } from "vitest"; -import { calculateTurnCost, recordTurnUsage } from "../src/session-cost.ts"; -import { coreToolDecision } from "../src/tool-fallback.ts"; -import { divergent, spec } from "./fixtures/catalogs.ts"; +import { divergent, spec } from "./support/catalogs.ts"; +import { + calculateTurnCost, + coreToolDecision, + recordTurnUsage, +} from "./support/src.ts"; describe("the free flag is the plane's row", () => { it("injects the free tier's tools on the plane that says free, and not on the other", () => { diff --git a/test/plane-lookup.test.ts b/test/plane-lookup.test.ts index acc4932c..38fbf173 100644 --- a/test/plane-lookup.test.ts +++ b/test/plane-lookup.test.ts @@ -10,23 +10,26 @@ * the shipped one: a `models.dev` payload → the parser → the active maps → the * lookup. Nothing here reaches a synthetic consumer of the plugin's internals. * - * **The installation is per FILE, not per test.** `refreshCatalog` merges by id - * and cannot remove rows, so a synthetic world persists for the rest of this - * file — which is why this file holds nothing else, and why its rows carry ids - * no other test names. + * **The installation is per TEST.** `refreshCatalog` merges by id and cannot + * remove rows, but the suite's setup file (`test/support/reset-process-state.ts`) + * resets the live catalog after every test, so a world installed once per file + * would be gone by the second case. Installing before each one keeps every case + * independent of the order it runs in; the rows still carry ids no other test + * names. * * @module test/plane-lookup.test */ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { payload } from "./support/catalogs.ts"; import { + coreToolDecision, findModelSpec, findModelSpecOn, + isCoreToolModel, refreshCatalog, -} from "../src/models-catalog.ts"; -import { coreToolDecision, isCoreToolModel } from "../src/tool-fallback.ts"; -import { payload } from "./fixtures/catalogs.ts"; +} from "./support/src.ts"; /** A model the two planes describe differently: free on Go, paid on Zen. */ const DIVERGENT = "acme-divergent-scratchpad"; @@ -50,14 +53,7 @@ const install = async (): Promise => { await refreshCatalog(async () => Response.json(body), true); }; -beforeAll(install, 15_000); - -/** Other suites in this process must not see the synthetic rows. */ -afterAll(() => { - // Nothing to undo: `refreshCatalog` has no removal, and the rows are named so - // that no other fixture collides with them. The next process gets the shim. - expect(DIVERGENT.length).toBeGreaterThan(0); -}); +beforeEach(install, 15_000); describe("the lookup reads the plane's own row", () => { it("parses the divergent world into two rows that disagree", () => { diff --git a/test/probe-verdict.test.ts b/test/probe-verdict.test.ts index f05c7ad6..0388a58a 100644 --- a/test/probe-verdict.test.ts +++ b/test/probe-verdict.test.ts @@ -79,6 +79,26 @@ describe("a retirement", () => { // the case this rule exists for (`gemini-3-flash` was listed and refused). expect(retiredAtTheGateway({ status: 200, summary: "ok" })).toBe(false); }); + + it("is an upstream's withdrawal too, but only when a 400 says so", () => { + // Measured on a free model the catalog still listed: the gateway relays the + // upstream's answer under a 400, the same status a malformed request gets. + const withdrawn = { + status: 400, + summary: "Upstream request failed: Model is unavailable", + }; + expect(retiredAtTheGateway(withdrawn)).toBe(true); + expect(usableAsSubject(withdrawn)).toBe(false); + // A 400 for any other reason is the request's fault, and a case about the + // route has to see it rather than walk past it. + const malformed = { status: 400, summary: "Invalid request body" }; + expect(retiredAtTheGateway(malformed)).toBe(false); + expect(usableAsSubject(malformed)).toBe(true); + // The words under another status are not this shape. + expect( + retiredAtTheGateway({ status: 500, summary: "Model is unavailable" }) + ).toBe(false); + }); }); describe("the busy gateway", () => { diff --git a/test/project-name.test.ts b/test/project-name.test.ts index 5ec9211d..a5972ac9 100644 --- a/test/project-name.test.ts +++ b/test/project-name.test.ts @@ -15,7 +15,7 @@ import { basename, join } from "node:path"; import { describe, expect, it } from "vitest"; -import { projectNameFrom } from "../src/stream-hook.ts"; +import { projectNameFrom } from "./support/src.ts"; describe("projectNameFrom", () => { it("takes the folder name from a POSIX path", () => { diff --git a/test/public-api.test.ts b/test/public-api.test.ts new file mode 100644 index 00000000..b246f70a --- /dev/null +++ b/test/public-api.test.ts @@ -0,0 +1,124 @@ +/** + * The package's public API, pinned name by name. + * + * `src/index.ts` is what a consumer of `dsh-opencode-patch` can import, and + * the host reads four names off it: `apply`, `Config`, `inject` and `name`. + * Anything else the barrel exports is a promise the package did not mean to + * make, so the list is pinned here and a change to it is a reviewed change to + * this file, not a side effect of an edit somewhere in `src`. + * + * Two readings, because they can disagree: the SOURCE parse sees type exports, + * which vanish at runtime, and the RUNTIME import sees what a consumer's + * `import *` really gets. The subject of this file is the barrel itself, so it + * imports `src/index.ts` directly rather than through `test/support/src.ts`. + * + * @module test/public-api.test + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { blankCommentLines } from "./support/strip-comments.ts"; + +const ROOT = join(import.meta.dirname, ".."); +const BARREL = join(ROOT, "src", "index.ts"); + +/** The runtime names: what `import * as barrel` answers. */ +const VALUE_EXPORTS: readonly string[] = ["Config", "apply", "inject", "name"]; + +/** + * The type-only names: erased at runtime, still part of the contract. + * `ModelRouteConfig` is here because `PluginConfig.modelRoutes` names it. + */ +const TYPE_EXPORTS: readonly string[] = [ + "EndpointFamily", + "KeySourcePolicy", + "ModelOverrideConfig", + "ModelRouteConfig", + "PluginConfig", + "ResolvedPluginConfig", +]; + +/** + * Every name the barrel's `export { … } from "./…"` blocks pass on, as the + * consumer sees it (an `as` alias is the exported name), with `type ` dropped. + */ +const barrelExportNames = (source: string): string[] => + [...source.matchAll(/^export (?:type )?\{([^}]*)\} from "\.\/[^"]+";/gmu)] + .flatMap((match) => (match[1] ?? "").split(",")) + .map((entry) => + entry + .trim() + .replace(/^type\s+/u, "") + .replace(/^[\w$]+\s+as\s+/u, "") + ) + .filter((entry) => entry !== ""); + +/** + * What is left of the barrel once comments and every block + * {@link barrelExportNames} reads are removed. Empty means the parse was + * total: an `export *`, an `export type *`, a local `export interface` or a + * single-quoted re-export would remain here and fail the pin, instead of + * widening the type half of the contract unseen. + */ +const unparsedRemainder = (source: string): string => + blankCommentLines(source) + .replaceAll(/^export (?:type )?\{[^}]*\} from "\.\/[^"]+";/gmu, "") + .trim(); + +describe("public API · src/index.ts", () => { + it("parses an export block the way a consumer reads it", () => { + // A known answer, so an equal list below means the parser ran. + expect( + barrelExportNames( + 'export { a, type B } from "./x.ts";\nexport { type C } from "./y.ts";\nexport { d as e } from "./z.ts";' + ) + ).toEqual(["a", "B", "C", "e"]); + }); + + it("reads the whole barrel: anything the parser skips is left over", () => { + // Known answers, so an empty remainder below means the check can see. + expect( + unparsedRemainder('/** doc */\n// note\nexport { a } from "./x.ts";\n') + ).toBe(""); + for (const widening of [ + 'export type * from "./x.ts";', + 'export * from "./x.ts";', + "export interface Extra { a: 1 }", + "export type Extra = string;", + "export { a } from './x.ts';", + ]) { + expect( + unparsedRemainder(`export { a } from "./x.ts";\n${widening}`) + ).toBe(widening); + // A `//` comment holding a glob's `/*`, then the widening, then a doc + // block: the `/*` must open nothing that swallows the widening. + expect( + unparsedRemainder( + `// internals live in src/* and stay there\n${widening}\n/** The host entry. */\nexport { apply } from "./lifecycle.ts";\n` + ) + ).toBe(widening); + } + }); + + it("the barrel exports exactly the pinned names (source parse)", () => { + const source = readFileSync(BARREL, "utf8"); + expect(unparsedRemainder(source)).toBe(""); + expect(barrelExportNames(source).toSorted()).toEqual( + [...VALUE_EXPORTS, ...TYPE_EXPORTS].toSorted() + ); + }); + + it("the barrel's runtime surface is the pinned value names, and the four the host calls are real", async () => { + const barrel = await import("../src/index.ts"); + expect(Object.keys(barrel).toSorted()).toEqual(VALUE_EXPORTS.toSorted()); + const pkg: unknown = JSON.parse( + readFileSync(join(ROOT, "package.json"), "utf8") + ); + expect(typeof barrel.apply).toBe("function"); + expect(typeof barrel.Config).toBe("function"); + expect(barrel.inject).toEqual(["llm"]); + expect(barrel.name).toBe((pkg as { name: string }).name); + }); +}); diff --git a/test/purge-contract.test.ts b/test/purge-contract.test.ts index 9d6d5977..0489f81b 100644 --- a/test/purge-contract.test.ts +++ b/test/purge-contract.test.ts @@ -9,7 +9,7 @@ */ import { describe, expect, it } from "vitest"; -import { parsePurgeResult } from "../src/purge-contract.ts"; +import { parsePurgeResult } from "./support/src.ts"; describe("purge contract · the result codec", () => { it("accepts a result and keeps only the fields the card renders", () => { diff --git a/test/purge-service.test.ts b/test/purge-service.test.ts index 55c993eb..daa65693 100644 --- a/test/purge-service.test.ts +++ b/test/purge-service.test.ts @@ -8,11 +8,11 @@ */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { parseModelsDevCatalog } from "../src/models-catalog.ts"; import { ModelPurgeService, + parseModelsDevCatalog, stripRetiredReferences, -} from "../src/purge-service.ts"; +} from "./support/src.ts"; import { createMockContext } from "./test-helpers.ts"; /** A registry reply with one retired id per plane and one live id beside it. */ diff --git a/test/remote-contract.test.ts b/test/remote-contract.test.ts index 4b0efc19..b9ae5e65 100644 --- a/test/remote-contract.test.ts +++ b/test/remote-contract.test.ts @@ -20,39 +20,114 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { patchRemote } from "../src/settings-page.tsx"; +import { patchRemote } from "./support/src.ts"; const SRC = join(import.meta.dirname, "..", "src"); +/** Every `export const NAME = "value"` in `src/`, so a constant resolves. */ +const exportedStrings = (): Map => { + const constants = new Map(); + for (const file of readdirSync(SRC).filter((name) => name.endsWith(".ts"))) { + const source = readFileSync(join(SRC, file), "utf8"); + for (const match of source.matchAll( + /export const ([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]+)"/gu + )) { + if (match[1] !== undefined && match[2] !== undefined) { + constants.set(match[1], match[2]); + } + } + } + return constants; +}; + +/** One remote service the host mounts: its file, class and namespace. */ +interface HostService { + readonly file: string; + readonly className: string; + readonly namespace: string; +} + /** - * Every namespace the host mounts a remote service under. + * Every remote service the host mounts, with the namespace it mounts under. * * Read from the source rather than imported: the services publish their - * namespace through `super(ctx, "name")`, and instantiating them needs a cordis - * context. A grep over the tree is the honest measurement of "what does the host - * mount", and it fails loudly if the pattern ever changes. + * namespace through `super(ctx, name)`, and instantiating them needs a cordis + * context. The name may be a literal or an exported constant, and both resolve: + * matching only the literal let `opencodeStore` — passed as `STORE_NAMESPACE` — + * through with no client descriptor, and the card's inspection row could never + * answer. */ -const hostNamespaces = (): string[] => { - const found = new Set(); +const hostServices = (): HostService[] => { + const constants = exportedStrings(); + const services: HostService[] = []; for (const file of readdirSync(SRC).filter((name) => name.endsWith(".ts"))) { const source = readFileSync(join(SRC, file), "utf8"); - for (const match of source.matchAll( - /super\(\s*ctx\s+as\s+never\s*,\s*"([^"]+)"\s*\)/gu - )) { - if (match[1] !== undefined) { - found.add(match[1]); - } + const className = /export class (\w+) extends TypertRemoteService/u.exec( + source + )?.[1]; + const argument = + /super\(\s*ctx\s+as\s+never\s*,\s*("([^"]+)"|[A-Z_][A-Z0-9_]*)\s*\)/u.exec( + source + ); + if (className === undefined || argument === null) { + continue; } + const namespace = + argument[2] ?? + constants.get(argument[1] ?? "") ?? + `unresolved:${argument[1]}`; + services.push({ className, file, namespace }); } - return [...found].toSorted(); + return services.toSorted((a, b) => a.namespace.localeCompare(b.namespace)); }; +const hostNamespaces = (): string[] => + hostServices().map((service) => service.namespace); + +/** The prototype key `remoteMethods()` reads, as the services spell it. */ +const REMOTE_METHOD_DESCRIPTOR_KEY = + "@deepseek-ai/dsh-typert-protocol/remote-methods"; + describe("remote contract · host and client agree", () => { it("finds the host namespaces, so the check is not vacuous", () => { // A grep that matches nothing is indistinguishable from a clean result; // these two are the services this plugin mounts today. expect(hostNamespaces()).toContain("opencodeGoUsage"); expect(hostNamespaces()).toContain("opencodeModelPurge"); + expect(hostNamespaces()).toContain("opencodeStore"); + expect( + hostNamespaces().filter((name) => name.startsWith("unresolved:")) + ).toEqual([]); + }); + + it("registers a descriptor for every Remote METHOD each service declares", async () => { + // Per method, not per namespace: a namespace with one descriptor and a + // second declared method would pass the namespace check and still leave + // the second unreachable. + const declared = new Set( + patchRemote.descriptors.map((d) => `${d.service}.${d.method}`) + ); + const missing: string[] = []; + let methods = 0; + for (const service of hostServices()) { + const module = (await import(join(SRC, service.file))) as Record< + string, + { prototype: Record } + >; + const marker = module[service.className]?.prototype[ + REMOTE_METHOD_DESCRIPTOR_KEY + ] as { methods?: readonly { method: string }[] } | undefined; + for (const { method } of marker?.methods ?? []) { + methods += 1; + if (!declared.has(`${service.namespace}.${method}`)) { + missing.push(`${service.namespace}.${method}`); + } + } + } + // Every service declares at least one method, so a marker read that found + // nothing would show here rather than as a clean pass. + expect(methods).toBeGreaterThanOrEqual(hostServices().length); + expect(missing).toEqual([]); }); it("registers a descriptor for every namespace the host mounts", () => { diff --git a/test/request-shape.test.ts b/test/request-shape.test.ts index e2a14ed6..efd06343 100644 --- a/test/request-shape.test.ts +++ b/test/request-shape.test.ts @@ -11,14 +11,52 @@ import { describe, expect, it } from "vitest"; import { ANTHROPIC_VERSION, + INTERNAL_ROUTES, authHeaderFor, + catalogPlaneForRoute, gatewayPlaneFor, headerSourceOf, isGoPlaneUrl, + isZenProvider, modelIdOf, requestUrlOf, toolNamesOf, -} from "../src/utils/request-shape.ts"; +} from "./support/src.ts"; + +describe("request-shape · isZenProvider", () => { + it("request-shape · isZenProvider: the Zen Google route is Zen, the Go routes are Go", () => { + // `opencode-google` is the Zen plane's Google route. It starts with + // `opencode-go`, so a predicate that looked for `go` anywhere read it as the + // subscription plan; the plane is `opencode-go` or one of its own + // `opencode-go-*` routes, and nothing else. + expect(isZenProvider("opencode-google")).toBe(true); + expect(isZenProvider("opencode-go-google")).toBe(false); + expect(isZenProvider("opencode-go-responses")).toBe(false); + expect(isZenProvider("opencode-go-anthropic")).toBe(false); + expect(isZenProvider("opencode-go")).toBe(false); + expect(isZenProvider("OpenCode-Go")).toBe(false); + expect(isZenProvider("opencode")).toBe(true); + expect(isZenProvider("OPENCODE")).toBe(true); + expect(isZenProvider("opencode-anthropic")).toBe(true); + expect(isZenProvider("opencode-responses")).toBe(true); + expect(isZenProvider("deepseek")).toBe(false); + expect(isZenProvider()).toBe(false); + }); + + it("agrees with catalogPlaneForRoute on every route the plugin serves", () => { + // The defect was these two predicates disagreeing; a route added to or + // renamed in responses-routes.ts must not let them drift apart again. The + // set is bounded on purpose: outside OpenCode routes the two differ by + // design (isZenProvider wants an `opencode` name; the plane defaults). + const routes = ["opencode", "opencode-go", ...INTERNAL_ROUTES]; + expect(routes.length).toBeGreaterThan(2); + for (const route of routes) { + expect(isZenProvider(route), route).toBe( + catalogPlaneForRoute(route) === "zen" + ); + } + }); +}); describe("request-shape · the plane of a path", () => { it("reads the Go plane out of both endpoint families", () => { diff --git a/test/response-errors.test.ts b/test/response-errors.test.ts index 1fe55573..6d7ef177 100644 --- a/test/response-errors.test.ts +++ b/test/response-errors.test.ts @@ -28,9 +28,9 @@ import { vi, } from "vitest"; -import { clearCapturedApiKeys } from "../src/key-capture.ts"; import type { GatewayErrorPayload } from "./mock/protocols/index.ts"; import { GatewayTestRunner, ZEN_KEY } from "./mock/testing-kit.ts"; +import { clearCapturedApiKeys } from "./support/src.ts"; let runner: GatewayTestRunner; diff --git a/test/responses-provider.test.ts b/test/responses-provider.test.ts index 0a0efa65..db7b8fcf 100644 --- a/test/responses-provider.test.ts +++ b/test/responses-provider.test.ts @@ -23,30 +23,29 @@ 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 { + type CordisContext, + GOOGLE_INTERNAL_ROUTE, + OPENCODE_PLANE_API, RESPONSES_SDK, ROUTE_FOR_PLANE_PROTOCOL, + SELF_MOUNTED_ROUTES, + baseForProtocol, + declaredProviderModels, getLiveGoCatalog, getLiveZenCatalog, inheritedCredentialRef, internalRouteFor, loadPiAi, + loadPiAiAdapterHost, + loadPiAiModule, modelsForSdk, narrowToDeclared, + reasoningEffortsFor, registerResponsesProvider, undescribedDeclaredModels, unroutedDeclaredModels, - GOOGLE_INTERNAL_ROUTE, - SELF_MOUNTED_ROUTES, -} from "../src/index.ts"; -import type { CordisContext } from "../src/index.ts"; -import { baseForProtocol } from "../src/pi-provider.ts"; -import { - loadPiAiAdapterHost, - loadPiAiModule, - reasoningEffortsFor, -} from "../src/responses-provider.ts"; +} from "./support/src.ts"; const MUSE = "muse-spark-1.2"; @@ -1545,9 +1544,9 @@ describe("responses-provider · declared models a missing route would strand", ( ); it("names the model and the protocol nothing serves", () => { - // The case: `internalRoutes: false`, no declared provider for this protocol, - // and a model the user selected that speaks it. Silence would leave the model - // in the picker with every call failing. + // The case: no declared provider for this protocol, and a model the user + // selected that speaks it. Silence would leave the model in the picker + // with every call failing. if (anthropic === undefined) { throw new Error("the zen catalog serves no anthropic model"); } @@ -1694,7 +1693,6 @@ describe("responses-provider · the declaration needs no credential", () => { // opencode: no models could be read for the route - it declares none, or // its credential is not resolvable in this process - so no internal route // is mounted for it - const { declaredProviderModels } = await import("../src/go-discovery.ts"); const ctx = { loader: { *entries() { @@ -1808,6 +1806,89 @@ describe("Go Google bridge mounting", () => { expect(host.adapters.has("opencode-go-google")).toBe(false); }); + it("hands back the bridges' disposer when every internal route is already declared", async () => { + // The bridge mounts before the wrapper's routes are weighed, so an early + // exit that answered `undefined` left both Google routes registered with + // nothing able to withdraw them — and the next reload's bridge then failed + // DUPLICATE_ADAPTER. Every wrapper-mounted route is declared here, which is + // the exit that leaves nothing to mount. + const host = createHost(); + const wrapperRoutes = Object.values(ROUTE_FOR_PLANE_PROTOCOL) + .flatMap((plane) => Object.values(plane.routes)) + .filter((route) => !SELF_MOUNTED_ROUTES.has(route)); + expect(wrapperRoutes.length).toBeGreaterThan(0); + for (const route of wrapperRoutes) { + host.adapters.set(route, { id: route }); + } + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + try { + const stop = await registerResponsesProvider(host.ctx); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("every internal route is already declared") + ); + expect(host.adapters.has("opencode-google")).toBe(true); + expect(host.adapters.has("opencode-go-google")).toBe(true); + expect(stop).toBeTypeOf("function"); + stop?.(); + expect(host.adapters.has("opencode-google")).toBe(false); + expect(host.adapters.has("opencode-go-google")).toBe(false); + // The disposer withdraws only what it mounted: the declared routes stay. + for (const route of wrapperRoutes) { + expect(host.adapters.has(route)).toBe(true); + } + // And a second activation can mount them again, which is what a reload does. + const again = await registerResponsesProvider(host.ctx); + expect(host.adapters.has("opencode-google")).toBe(true); + again?.(); + } finally { + info.mockRestore(); + } + }); + + it("withdraws routes and disposes the fiber when the mount fails to start", async () => { + // A route the mounted plugin registered before its startup error is live on + // the host. The failure path used to log "internal routes stay unregistered" + // and leave both the route and the fiber behind. + const host = createHost(); + const disposed = vi.fn(); + type Ctx = NonNullable; + const wrap = (ctx: Ctx): Ctx => { + const isolate = ctx.isolate as (name: string) => Ctx; + const plugin = ctx.plugin as ( + subject: unknown, + config?: unknown + ) => unknown; + Object.assign(ctx, { + isolate(this: Ctx, name: string) { + return wrap(isolate.call(this, name)); + }, + plugin(this: Ctx, subject: unknown, config?: unknown) { + plugin.call(this, subject, config); + return { + await: () => Promise.reject(new Error("startup failed")), + dispose: disposed, + }; + }, + }); + return ctx; + }; + wrap(host.ctx); + const stop = await registerResponsesProvider(host.ctx); + // Fiber disposal is fire-and-forget; let it run. + await Promise.resolve(); + expect(disposed).toHaveBeenCalledTimes(1); + // Only the two bridges remain; every route the failed mount took is gone. + expect( + ( + (host.ctx.llm?.listProviders?.() ?? []) as readonly { id: string }[] + ).map((provider) => provider.id) + ).toEqual(["opencode-google", "opencode-go-google"]); + // The bridges did mount, and the disposer handed back still withdraws them. + expect(host.adapters.has("opencode-google")).toBe(true); + stop?.(); + expect(host.adapters.has("opencode-google")).toBe(false); + }); + it("withdraws the first bridge if mounting the second fails", async () => { const host = createHost(); const llm = host.ctx.llm!; diff --git a/test/responses-routes.test.ts b/test/responses-routes.test.ts index adc1366c..55d57c3c 100644 --- a/test/responses-routes.test.ts +++ b/test/responses-routes.test.ts @@ -14,19 +14,19 @@ import { GOOGLE_SDK, GO_RESPONSES_ROUTE, MISTRAL_SDK, + OPENCODE_GO_CATALOG, + OPENCODE_ZEN_CATALOG, + RESPONSES_ROUTE, + RESPONSES_SDK, + ROUTE_FOR_PLANE_PROTOCOL, catalogPlaneForRoute, findModelSpec, findModelSpecOn, internalRouteFor, + isServableModel, isServableSdk, - OPENCODE_GO_CATALOG, - OPENCODE_ZEN_CATALOG, parseModelsDevCatalog, - RESPONSES_ROUTE, - RESPONSES_SDK, - ROUTE_FOR_PLANE_PROTOCOL, -} from "../src/index.ts"; -import { isServableModel } from "../src/models-discovery.ts"; +} from "./support/src.ts"; const MUSE = "muse-spark-1.2"; diff --git a/test/retry.test.ts b/test/retry.test.ts index 3699125a..74d43f0c 100644 --- a/test/retry.test.ts +++ b/test/retry.test.ts @@ -1,17 +1,33 @@ import { describe, expect, it, vi } from "vitest"; -import { retryUntilAnswered } from "../src/utils/retry.ts"; +import { + backoffOver, + retryUntilAnswered, + type RetryOptions, + type RetryTimer, +} from "./support/src.ts"; describe("utils/retry: retryUntilAnswered", () => { const noSleep = () => Promise.resolve(); + const run = ( + read: () => Promise, + attempts: number, + extra: Partial = {} + ): Promise => + retryUntilAnswered(read, { + attempts, + sleep: noSleep, + ...extra, + }); + it("returns the first answer without retrying when immediately defined", async () => { let calls = 0; const read = () => { calls += 1; return Promise.resolve("ready"); }; - const result = await retryUntilAnswered(read, 5, noSleep); + const result = await run(read, 5); expect(result).toBe("ready"); expect(calls).toBe(1); }); @@ -22,7 +38,7 @@ describe("utils/retry: retryUntilAnswered", () => { calls += 1; return Promise.resolve(calls < 3 ? undefined : "finally"); }; - const result = await retryUntilAnswered(read, 5, noSleep); + const result = await run(read, 5); expect(result).toBe("finally"); expect(calls).toBe(3); }); @@ -39,7 +55,7 @@ describe("utils/retry: retryUntilAnswered", () => { } return Promise.resolve("recovered"); }; - const result = await retryUntilAnswered(read, 5, noSleep); + const result = await run(read, 5); expect(result).toBe("recovered"); expect(calls).toBe(3); }); @@ -49,28 +65,48 @@ describe("utils/retry: retryUntilAnswered", () => { const read = async () => { calls += 1; }; - const result = await retryUntilAnswered(read, 4, noSleep); + const result = await run(read, 4); expect(result).toBeUndefined(); expect(calls).toBe(4); }); - it("returns undefined and logs a warning when all attempts throw errors", async () => { + it("returns undefined and tells the caller's logger when all attempts throw errors", async () => { let calls = 0; - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warn = vi.fn<(message: string, error: unknown) => void>(); const read = () => { calls += 1; return Promise.reject(new Error("repeated outage")); }; + const result = await run(read, 3, { warn }); + expect(result).toBeUndefined(); + expect(calls).toBe(3); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "[dsh-opencode-patch] retry: all attempts failed", + expect.any(Error) + ); + }); + + it("stays silent when every attempt throws and no logger is supplied", async () => { + // Silence is the claim, so the console is watched: a fallback to + // `console.warn` when `warn` is omitted would answer the same result. + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); try { - const result = await retryUntilAnswered(read, 3, noSleep); - expect(result).toBeUndefined(); - expect(calls).toBe(3); - expect(warnSpy).toHaveBeenCalledWith( - "[dsh-opencode-patch] retry: all attempts failed", - expect.any(Error) - ); + let calls = 0; + const read = () => { + calls += 1; + return Promise.reject(new Error("repeated outage")); + }; + await expect(run(read, 2)).resolves.toBeUndefined(); + expect(calls).toBe(2); + expect(consoleWarn).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); } finally { - warnSpy.mockRestore(); + consoleWarn.mockRestore(); + consoleError.mockRestore(); } }); @@ -81,7 +117,7 @@ describe("utils/retry: retryUntilAnswered", () => { calls += 1; return "unreached"; }; - const result = await retryUntilAnswered(read, 5, sleep, () => false); + const result = await run(read, 5, { live: () => false, sleep }); expect(result).toBeUndefined(); expect(calls).toBe(0); expect(sleep).not.toHaveBeenCalled(); @@ -94,20 +130,82 @@ describe("utils/retry: retryUntilAnswered", () => { calls += 1; alive = false; }; - const result = await retryUntilAnswered(read, 5, noSleep, () => alive); + const result = await run(read, 5, { live: () => alive }); expect(result).toBeUndefined(); expect(calls).toBe(1); }); - it("uses the default sleep calculation when sleep is omitted", async () => { - // Testing that the default sleep parameter can be executed without error + it("defaults to five attempts", async () => { + let calls = 0; + const read = async () => { + calls += 1; + }; + const result = await retryUntilAnswered(read, { sleep: noSleep }); + expect(result).toBeUndefined(); + expect(calls).toBe(5); + }); + + it("waits on the schedule backoffOver builds over the caller's timer", async () => { + // No wall clock: the timer runs its callback at once and records the delay + // it was asked for, so the default schedule is observed rather than waited. let calls = 0; + const delays: number[] = []; + const immediateTimer: RetryTimer = (callback, delayMs) => { + delays.push(delayMs); + callback(); + }; const read = () => { calls += 1; return Promise.resolve(calls < 2 ? undefined : "done"); }; - const result = await retryUntilAnswered(read, 2); + const result = await retryUntilAnswered(read, { + attempts: 2, + sleep: backoffOver(immediateTimer), + }); expect(result).toBe("done"); expect(calls).toBe(2); + expect(delays).toEqual([300]); + }); +}); + +describe("utils/retry: backoffOver", () => { + /** A timer that records each request and runs nothing until told to. */ + const recordingTimer = () => { + const scheduled: { callback: () => void; delayMs: number }[] = []; + const timer: RetryTimer = (callback, delayMs) => { + scheduled.push({ callback, delayMs }); + }; + return { scheduled, timer }; + }; + + it("schedules a linear backoff and resolves only when the timer fires", async () => { + const { scheduled, timer } = recordingTimer(); + const sleep = backoffOver(timer); + const settled: number[] = []; + const pending = [0, 1, 2].map(async (attempt) => { + await sleep(attempt); + settled.push(attempt); + }); + + expect(scheduled.map((entry) => entry.delayMs)).toEqual([300, 600, 900]); + await Promise.resolve(); + expect(settled).toEqual([]); + + scheduled[1]?.callback(); + await pending[1]; + expect(settled).toEqual([1]); + + scheduled[0]?.callback(); + scheduled[2]?.callback(); + await Promise.all(pending); + expect(settled.toSorted((a, b) => a - b)).toEqual([0, 1, 2]); + }); + + it("takes its base delay as a parameter", () => { + const { scheduled, timer } = recordingTimer(); + const sleep = backoffOver(timer, 100); + void sleep(0); + void sleep(1); + expect(scheduled.map((entry) => entry.delayMs)).toEqual([100, 200]); }); }); diff --git a/test/session-cost.test.ts b/test/session-cost.test.ts index e7fc45b6..77e1431b 100644 --- a/test/session-cost.test.ts +++ b/test/session-cost.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + type ModelCostRate, calculateTurnCost, clearSessionUsageStore, describeModel, @@ -18,8 +19,7 @@ import { formatUsd, getSessionUsage, recordTurnUsage, - type ModelCostRate, -} from "../src/index.ts"; +} from "./support/src.ts"; afterEach(() => { vi.unstubAllGlobals(); @@ -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/session.test.ts b/test/session.test.ts index 2898c06e..588edd9a 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -10,20 +10,20 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { readSessionMetaResolver } from "../src/cordis-context.ts"; import { + type ActiveTurnState, SESSION_HEADER, + fallbackSessionId, hasSessionHeader, headerValueFor, isOpenCodeMessageId, isOpenCodeSessionId, newOpenCodeMessageId, - fallbackSessionId, openCodeSessionIdFor, + readSessionMetaResolver, sessionIdForRequest, - type ActiveTurnState, withStore, -} from "../src/index.ts"; +} from "./support/src.ts"; import { SESSION_RE, createMockStoreStream } from "./test-helpers.ts"; afterEach(() => { diff --git a/test/settings-boolean-field.test.tsx b/test/settings-boolean-field.test.tsx index 612ce6cf..d82c04f4 100644 --- a/test/settings-boolean-field.test.tsx +++ b/test/settings-boolean-field.test.tsx @@ -11,10 +11,7 @@ import { describe, expect, it, vi } from "vitest"; -import { - type BooleanFieldProps, - SettingsBooleanField, -} from "../src/settings-boolean-field.tsx"; +import { type BooleanFieldProps, SettingsBooleanField } from "./support/src.ts"; import { firstOf } from "./test-helpers.ts"; const baseProps = ( diff --git a/test/settings-card-purge.test.tsx b/test/settings-card-purge.test.tsx index 3d1073d1..500ea342 100644 --- a/test/settings-card-purge.test.tsx +++ b/test/settings-card-purge.test.tsx @@ -328,6 +328,36 @@ describe("settings-card: the states that only exist after an interaction", () => }); await settle(); expect(host.textContent).toContain(en.purgeFailed); + // In the host's warning TEXT colour: a refusal read in caption grey is + // indistinguishable from a success that removed nothing. + const line = [...host.querySelectorAll("div")].find((d) => + (d.textContent ?? "").startsWith(en.purgeFailed) + ); + expect(line?.style.color).toBe("var(--dsw-alias-state-warn-label)"); + } finally { + cleanup(); + } + }); + + it("shows an outcome that is not a refusal in caption text", async () => { + const { act, host, settle, cleanup } = await mountCard({ + purgeDeprecated: async () => ({ + kind: "purged", + result: { catalogRead: true, removed: [] }, + }), + }); + try { + const button = [...host.querySelectorAll("button")].find((b) => + (b.textContent ?? "").includes(en.purgeAction) + ); + act(() => { + button?.click(); + }); + await settle(); + const line = [...host.querySelectorAll("div")].find((d) => + (d.textContent ?? "").startsWith(en.purgeNothing) + ); + expect(line?.style.color).toBe("var(--dsw-alias-label-caption)"); } finally { cleanup(); } diff --git a/test/settings-choice-field.test.tsx b/test/settings-choice-field.test.tsx index 17440e50..9b0b34c6 100644 --- a/test/settings-choice-field.test.tsx +++ b/test/settings-choice-field.test.tsx @@ -10,10 +10,7 @@ import { describe, expect, it, vi } from "vitest"; -import { - type ChoiceFieldProps, - SettingsChoiceField, -} from "../src/settings-choice-field.tsx"; +import { type ChoiceFieldProps, SettingsChoiceField } from "./support/src.ts"; import { findAll, firstOf } from "./test-helpers.ts"; const OPTIONS = [ diff --git a/test/settings-copy.test.ts b/test/settings-copy.test.ts index 01b8bf42..892a360b 100644 --- a/test/settings-copy.test.ts +++ b/test/settings-copy.test.ts @@ -19,7 +19,7 @@ import assert from "node:assert/strict"; import { describe, expect, it } from "vitest"; -import { en, withBundleCopy, zh } from "../src/settings-copy.ts"; +import { en, withBundleCopy, zh } from "./support/src.ts"; /** A seat whose dictionary predates every key: it answers with the key itself. */ const staleSeat = (key: string): string => key; diff --git a/test/settings-field-shell.test.tsx b/test/settings-field-shell.test.tsx index 6481851c..20ff57ae 100644 --- a/test/settings-field-shell.test.tsx +++ b/test/settings-field-shell.test.tsx @@ -12,10 +12,7 @@ import assert from "node:assert/strict"; import { describe, expect, it, vi } from "vitest"; -import { - type FieldShellProps, - SettingsFieldShell, -} from "../src/settings-field-shell.tsx"; +import { type FieldShellProps, SettingsFieldShell } from "./support/src.ts"; import { collectText, findAll, diff --git a/test/settings-page.test.tsx b/test/settings-page.test.tsx index c4567cfa..560d8b66 100644 --- a/test/settings-page.test.tsx +++ b/test/settings-page.test.tsx @@ -3,21 +3,21 @@ import assert from "node:assert/strict"; import { describe, expect, it, vi } from "vitest"; import { + applySettingsPage as apply, + CARD_FIELDS, + Config, + CONFIG_ONLY_FIELDS, + en, ENDPOINT_FAMILIES, KEY_SOURCE_POLICIES, -} from "../src/config-values.ts"; -import { Config } from "../src/index.ts"; -import { en } from "../src/settings-copy.ts"; -import { CARD_FIELDS, CONFIG_ONLY_FIELDS } from "../src/settings-fields.ts"; -import { - apply, LEGACY_NS, - SCOPED_PKG, NS, PKG, + remoteNamespace, + remoteNamespaceOf, + SCOPED_PKG, SPECS, -} from "../src/settings-page.tsx"; -import { remoteNamespace, remoteNamespaceOf } from "../src/settings-remote.ts"; +} from "./support/src.ts"; import { elementName, findAll, @@ -1219,8 +1219,14 @@ describe("settings-page: field specs", () => { // The card deliberately hides the override knobs, so the two lists must // together cover every schema key exactly once. A knob in neither would be // unreachable; a knob in both would contradict CONFIG_ONLY_FIELDS. + // + // The keys come from `Config.dict`, the schema's own field table. + // `Config({})` materialises only the keys that carry a default or are + // `.volatile()`, which is how `debugFile` (neither) went missing from both + // lists without this test noticing. // oxlint-disable-next-line unicorn/no-array-sort -- `Object.keys` returns a fresh array, so in-place sort mutates nothing shared. - const schemaKeys = Object.keys(Config({})).sort(); + const schemaKeys = Object.keys(Config.dict ?? {}).sort(); + expect(schemaKeys).toContain("debugFile"); const cardFields = CARD_FIELDS.map((entry) => entry.field); const configOnly = CONFIG_ONLY_FIELDS.map((entry) => entry.field); const covered = [...cardFields, ...configOnly]; @@ -1317,8 +1323,9 @@ describe("settings-page: every remote gets a mount", () => { // Namespaces, not descriptors: `opencodeGoUsage` carries more than one // endpoint (`read`, and the account-scoped `readAccount` the sibling // `dsh-opencode-go` account page calls), and both are the same mount. + // `opencodeStore` is the inspection row's; it was the one left out. expect(new Set(services)).toEqual( - new Set(["opencodeGoUsage", "opencodeModelPurge"]) + new Set(["opencodeGoUsage", "opencodeModelPurge", "opencodeStore"]) ); expect(mounted).toHaveLength(1); expect(mounted[0]?.package).toBe("dsh-opencode-patch"); diff --git a/test/settings-purge.test.ts b/test/settings-purge.test.ts index 0b34a917..0be6a2a5 100644 --- a/test/settings-purge.test.ts +++ b/test/settings-purge.test.ts @@ -50,6 +50,33 @@ describe("purgeDeprecatedModels", () => { }); }); +describe("purgeDeprecatedModels: the remote protocol's refusal", () => { + it("carries the host's refusal envelope in its own words", async () => { + // The remote protocol answers a failure as `{ ok: false, error }`, with + // `error` a serialized RemoteError: the reason is its `message`. + await expect( + purgeDeprecatedModels( + scopeWith(async () => ({ + error: new Error("no config editor on this host"), + ok: false, + })) + ) + ).resolves.toEqual({ + kind: "failed", + message: "no config editor on this host", + }); + }); + + it("names a refusal that carries no message", async () => { + await expect( + purgeDeprecatedModels(scopeWith(async () => ({ error: {}, ok: false }))) + ).resolves.toEqual({ + kind: "failed", + message: "the host refused the purge", + }); + }); +}); + describe("purgeDeprecatedModels: the paths a scope can take", () => { it("reads the namespace through ctx.get, the path that works at runtime", () => { // The `scope.remote.opencodeModelPurge` property read in the cases above is diff --git a/test/settings-remote.test.ts b/test/settings-remote.test.ts index 2be79823..b63ce116 100644 --- a/test/settings-remote.test.ts +++ b/test/settings-remote.test.ts @@ -18,7 +18,7 @@ import { callUsageRead, remoteNamespace, remoteNamespaceOf, -} from "../src/settings-remote.ts"; +} from "./support/src.ts"; describe("settings-remote · remoteNamespaceOf", () => { it("answers the namespace's calls when the remote serves them", () => { diff --git a/test/settings-store-inspect.test.tsx b/test/settings-store-inspect.test.tsx index db97239d..fc959318 100644 --- a/test/settings-store-inspect.test.tsx +++ b/test/settings-store-inspect.test.tsx @@ -269,4 +269,50 @@ describe("settings-store-inspect: the row", () => { host.remove(); } }); + + it("renders a problem note in the host's warning text colour", async () => { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + act(() => { + root.render( + createElement(StoreInspectRow, { + onInspect: () => + Promise.resolve({ kind: "unavailable" }), + t, + }) + ); + }); + const button = [...host.querySelectorAll("button")].find((b) => + (b.textContent ?? "").includes(t("storeInspect")) + ); + act(() => { + button?.click(); + }); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + + // The innermost div whose whole text is the note: with no rows, the + // report's wrapper carries the same text. + const note = [...host.querySelectorAll("div")].find( + (d) => + d.textContent === t("storeInspectUnavailable") && + d.querySelector("div") === null + ); + expect(note, "the note must be rendered").toBeDefined(); + expect(note?.style.color).toBe("var(--dsw-alias-state-warn-label)"); + } finally { + act(() => { + root.unmount(); + }); + host.remove(); + } + }); }); diff --git a/test/settings-store.test.ts b/test/settings-store.test.ts index 8ff980d8..ab9517c2 100644 --- a/test/settings-store.test.ts +++ b/test/settings-store.test.ts @@ -58,6 +58,43 @@ describe("inspectStore", () => { }); }); + it("keeps only the whitelisted fields, so a stray secret cannot reach the card", async () => { + // The seam's rule is "a credential never crosses it". The parser is what + // enforces that on this side: a field the host did not mean to send is + // dropped, not rendered. + const answer = { + account: { + accessToken: "fixture-token-not-real", + email: "someone@example.com", + type: "oauth", + }, + found: true, + token: "fixture-token-not-real", + }; + const outcome = await inspectStore(scopeWith(async () => answer)); + expect(outcome).toEqual({ + inspection: { + account: { email: "someone@example.com", type: "oauth" }, + found: true, + }, + kind: "inspected", + }); + expect(JSON.stringify(outcome)).not.toContain("fixture-token-not-real"); + }); + + it("reports the remote's own failure from an ok:false envelope", async () => { + // Read as a shape, this used to surface as "invalid inspection shape" — a + // message about the envelope instead of the host's reason. + const inspect = vi.fn(async () => ({ + error: { code: "opencode/store-unavailable", message: "no sqlite here" }, + ok: false, + })); + await expect(inspectStore(scopeWith(inspect))).resolves.toEqual({ + kind: "failed", + message: "no sqlite here", + }); + }); + it("rejects an answer that is not an inspection", async () => { // A shape check, not a wish: the card renders rows off this value, and a // half-built answer would print `undefined` beside a label. diff --git a/test/settings-usage.test.tsx b/test/settings-usage.test.tsx index 5e86a3c9..e6d3fb3b 100644 --- a/test/settings-usage.test.tsx +++ b/test/settings-usage.test.tsx @@ -8,7 +8,7 @@ import { render, waitFor } from "@testing-library/react"; */ import type { ReactNode } from "react"; import React from "react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { zh } from "../src/settings-copy.ts"; // @vitest-environment jsdom @@ -358,4 +358,40 @@ describe("settings-usage: reading until the remote answers", () => { ).toBeUndefined(); expect(calls).toBe(1); }); + + it("waits 300 ms on the real timer when no sleep is supplied", async () => { + // The production card passes no `sleep`, so this is the clock it runs on. + // A timer that never fired would leave the card refreshing for good. + vi.useFakeTimers(); + try { + let calls = 0; + const read = () => { + calls += 1; + return Promise.resolve(calls < 2 ? undefined : answered); + }; + const pending = readUntilAnswered(read, 2); + await vi.advanceTimersByTimeAsync(299); + expect(calls).toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(calls).toBe(2); + expect(await pending).toBe(answered); + } finally { + vi.useRealTimers(); + } + }); + + it("reports on the console when every attempt fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const read = () => Promise.reject(new Error("repeated outage")); + expect(await readUntilAnswered(read, 2, noSleep)).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "[dsh-opencode-patch] retry: all attempts failed", + expect.any(Error) + ); + } finally { + warn.mockRestore(); + } + }); }); diff --git a/test/stream-hook.test.ts b/test/stream-hook.test.ts index 9134b22b..52a61339 100644 --- a/test/stream-hook.test.ts +++ b/test/stream-hook.test.ts @@ -16,9 +16,22 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { projectNameFrom } from "../src/stream-hook.ts"; +import { + GO_GOOGLE_INTERNAL_ROUTE, + projectNameFrom, + recordDebug, + RESPONSES_ROUTE, +} from "./support/src.ts"; +import { collectUnknown, createMockStream } from "./test-helpers.ts"; + +// The debug sink is observed rather than written: a negative assertion over a +// file needs a wait to mean anything, and a mock makes "never called" exact. +vi.mock("../src/debug.ts", async (importOriginal) => ({ + ...(await importOriginal()), + recordDebug: vi.fn(), +})); const created: string[] = []; @@ -113,9 +126,9 @@ describe("stream-hook · the pass-through guards", () => { * serve. */ const hookWith = async (providers: string[]) => { - const { createStreamHook } = await import("../src/stream-hook.ts"); + const { createStreamHook, resolveConfig } = + await import("./support/src.ts"); const { AsyncLocalStorage } = await import("node:async_hooks"); - const { resolveConfig } = await import("../src/config.ts"); return createStreamHook( { logger: { info: () => {} } } as never, resolveConfig({ providers, usageEnabled: false } as never), @@ -189,6 +202,228 @@ describe("stream-hook · the pass-through guards", () => { }); }); +describe("stream-hook · the redirect forwards the caller's request", () => { + /** + * A model the plane serves on another protocol is handed to the route that + * speaks it. The caller's request goes with it whole: only `provider` changes + * (and, for Go-Google, the history is replayed into Google's shape). + * + * `toEqual` deliberately: it ignores a key whose value is `undefined`, so the + * pin holds whether or not the spread carries one. + */ + const hookFor = async ( + providers: string[], + api: string, + route: string, + stream: (options: unknown) => unknown + ) => { + const { createStreamHook, resolveConfig } = + await import("./support/src.ts"); + const { AsyncLocalStorage } = await import("node:async_hooks"); + return createStreamHook( + { + llm: { adapters: new Map([[route, {}]]), stream }, + logger: { info: () => {} }, + } as never, + resolveConfig({ + modelRoutes: { "probe-model": { api } }, + providers, + usageEnabled: false, + } as never), + new AsyncLocalStorage() + ); + }; + + let warn: { mockRestore: () => void } | undefined; + + beforeEach(() => { + // The probe id is in no catalog, so the hook warns once that the picker + // would not offer it. That line is not what these cases are about. + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warn?.mockRestore(); + }); + + it("hands the whole request to the protocol route with only provider replaced", async () => { + const stream = vi.fn((_options: unknown) => "redirected"); + const hook = await hookFor( + ["opencode"], + "openai-responses", + RESPONSES_ROUTE, + stream + ); + const options = { + messages: [], + model: "probe-model", + provider: "opencode", + sessionId: "s", + }; + const next = vi.fn(() => "next-result"); + + expect(hook(options, next)).toBe("redirected"); + expect(next).not.toHaveBeenCalled(); + expect(stream).toHaveBeenCalledTimes(1); + expect(stream.mock.calls[0]?.[0]).toEqual({ + ...options, + provider: RESPONSES_ROUTE, + }); + }); + + it("hands a Go-Google request over with its history in Google's shape", async () => { + const sentinel = { notIterable: true }; + const stream = vi.fn((_options: unknown) => sentinel); + const hook = await hookFor( + ["opencode-go"], + "google-generative-ai", + GO_GOOGLE_INTERNAL_ROUTE, + stream + ); + // The user message is the control: only an assistant turn the Go plane's + // Google adapter answered carries a replay identity to translate. + const user = { content: "hi", role: "user" }; + const replayState = { + response: { + api: "google-generative-ai", + kind: "pi-ai", + provider: "opencode-go", + }, + signature: "sig", + }; + const assistant = { + content: "hello", + role: "assistant", + source: { provider: "opencode-go", replayState }, + }; + const options = { + messages: [user, assistant], + model: "probe-model", + provider: "opencode-go", + sessionId: "s", + }; + const next = vi.fn(() => "next-result"); + + expect(hook(options, next)).toBe(sentinel); + expect(next).not.toHaveBeenCalled(); + expect(stream).toHaveBeenCalledTimes(1); + // A literal expectation, not one re-derived through the function under + // test: forwarding the history raw must fail here. + expect(stream.mock.calls[0]?.[0]).toEqual({ + ...options, + messages: [ + user, + { + content: "hello", + role: "assistant", + source: { + provider: GO_GOOGLE_INTERNAL_ROUTE, + replayState: { + response: { + api: "google-generative-ai", + kind: "pi-ai", + provider: GO_GOOGLE_INTERNAL_ROUTE, + }, + signature: "sig", + }, + }, + }, + ], + provider: GO_GOOGLE_INTERNAL_ROUTE, + }); + }); +}); + +describe("stream-hook · the debug gate", () => { + /** + * Naming `debugFile` writes the file; `debug` governs the console alone. The + * request half (lifecycle.ts) and this stream half are the two halves of one + * JSONL log, so they share that gate: the README's own example, `debug: false` + * beside a `debugFile` path, must produce a file holding both halves. + */ + const debugFile = join(tmpdir(), "dsh-stream-debug", "stream.jsonl"); + + const hookWith = async ( + debug: boolean, + file: string | undefined, + info: (...args: unknown[]) => void + ) => { + const { createStreamHook, resolveConfig } = + await import("./support/src.ts"); + const { AsyncLocalStorage } = await import("node:async_hooks"); + return createStreamHook( + { logger: { info } } as never, + resolveConfig({ + debug, + ...(file === undefined ? {} : { debugFile: file }), + providers: ["opencode"], + usageEnabled: false, + } as never), + new AsyncLocalStorage() + ); + }; + + /** Drive one turn through the hook and drain it. */ + const turn = async ( + debug: boolean, + { + info = () => {}, + withFile = true, + }: { + info?: (...args: unknown[]) => void; + withFile?: boolean; + } = {} + ): Promise => { + const hook = await hookWith(debug, withFile ? debugFile : undefined, info); + const result = hook( + { model: "m", provider: "opencode", sessionId: "s" }, + () => createMockStream("x") + ); + await collectUnknown(result as AsyncIterable); + }; + + beforeEach(() => { + vi.mocked(recordDebug).mockClear(); + }); + + it("stream-hook · the debug gate: writes debugFile while debug is off, and logs nothing", async () => { + const info = vi.fn<(...args: unknown[]) => void>(); + await turn(false, { info }); + expect(recordDebug).toHaveBeenCalledTimes(1); + const [, file] = vi.mocked(recordDebug).mock.calls[0] ?? []; + expect(file).toBe(debugFile); + expect(info).not.toHaveBeenCalled(); + }); + + it("writes one entry for the turn to debugFile and logs it while debug is on", async () => { + const info = vi.fn<(...args: unknown[]) => void>(); + await turn(true, { info }); + expect(recordDebug).toHaveBeenCalledTimes(1); + const [, file, entry] = vi.mocked(recordDebug).mock.calls[0] ?? []; + expect(file).toBe(debugFile); + expect(entry).toMatchObject({ + header: "x-opencode-session", + session: "s", + }); + expect(info).toHaveBeenCalledTimes(1); + }); + + it("logs the turn but writes no file while debug is on without a debugFile", async () => { + const info = vi.fn<(...args: unknown[]) => void>(); + await turn(true, { info, withFile: false }); + expect(recordDebug).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledTimes(1); + expect(info.mock.calls[0]).toContain("x-opencode-session"); + }); + + it("does nothing at all with debug off and no debugFile", async () => { + const info = vi.fn<(...args: unknown[]) => void>(); + await turn(false, { info, withFile: false }); + expect(recordDebug).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + }); +}); + describe("stream-hook · a turn is priced at ITS plane's rate", () => { /** * The two planes price the same id differently, and the plane-agnostic @@ -199,9 +434,9 @@ describe("stream-hook · a turn is priced at ITS plane's rate", () => { * meter showed the half. */ const hookWith = async (providers: string[]) => { - const { createStreamHook } = await import("../src/stream-hook.ts"); + const { createStreamHook, resolveConfig } = + await import("./support/src.ts"); const { AsyncLocalStorage } = await import("node:async_hooks"); - const { resolveConfig } = await import("../src/config.ts"); return createStreamHook( { logger: { info: () => {} } } as never, resolveConfig({ providers, usageEnabled: true } as never), @@ -227,7 +462,7 @@ describe("stream-hook · a turn is priced at ITS plane's rate", () => { it("prices a Zen turn at the Zen rate, and a Go turn at the Go rate", async () => { const { clearSessionUsageStore, getSessionUsage } = - await import("../src/session-cost.ts"); + await import("./support/src.ts"); clearSessionUsageStore(); const hook = await hookWith(["opencode", "opencode-go"]); diff --git a/test/strip-comments.test.ts b/test/strip-comments.test.ts new file mode 100644 index 00000000..a5f0d68b --- /dev/null +++ b/test/strip-comments.test.ts @@ -0,0 +1,89 @@ +/** + * Known answers for the comment blanker the source-reading gates share. + * + * @module test/strip-comments.test + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { blankCommentLines } from "./support/strip-comments.ts"; + +describe("blankCommentLines", () => { + it("blanks line comments and line-opening blocks, keeping the line count", () => { + const source = [ + "/**", + " * doc", + " */", + "const a = 1;", + " // note", + "/* one */ const b = 2;", + "const c = 3; // trailing stays", + ].join("\n"); + expect(blankCommentLines(source).split("\n")).toEqual([ + "", + "", + "", + "const a = 1;", + "", + " const b = 2;", + "const c = 3; // trailing stays", + ]); + }); + + it("opens no block at a `/*` inside a string, so the code after it stays", () => { + const source = 'x("a/*b");\nimport "../src/x.ts";\n// c */\n'; + expect(blankCommentLines(source)).toBe( + 'x("a/*b");\nimport "../src/x.ts";\n\n' + ); + }); + + it("sees a widening between a `//` comment holding `/*` and a later block", () => { + const source = + '// internals live in src/* and stay there\nexport type * from "./config.ts";\n/** The host entry. */\n'; + expect(blankCommentLines(source)).toBe( + '\nexport type * from "./config.ts";\n\n' + ); + }); + + it("carries a block reopened on a closing line on to the lines it spans", () => { + expect(blankCommentLines("/* a */ /* b\nc\n*/\nd")).toBe("\n\n\nd"); + expect(blankCommentLines("/* a */ /*\n * process.env\n */\ncode();")).toBe( + "\n\n\ncode();" + ); + }); + + it("reads a closing line's tail as code, a line comment, or a closed block", () => { + expect(blankCommentLines("/* a */ // b\ncode();")).toBe("\ncode();"); + expect(blankCommentLines("/* a */ /* b */ code();")).toBe(" code();"); + }); + + it("throws on a block that never closes, naming the line it opened on", () => { + // A template-literal line that begins with `/*` and is never closed would + // otherwise blank every line after it, the real import included. + const source = + 'const s = `\n/* glob\nimport "../src/x.ts";\n`;\nimport "../src/y.ts";\n'; + expect(() => blankCommentLines(source)).toThrow(/line 2/u); + }); + + it("hides no code line of the test file that once lost seventy", () => { + // test/package-checks.test.ts holds `"locale/*.json"` long before a `*/` + // in a comment; the old whole-file regex blanked the code between them. + const raw = readFileSync( + join(import.meta.dirname, "package-checks.test.ts"), + "utf8" + ); + expect(raw).toContain("locale/*.json"); + const blanked = blankCommentLines(raw).split("\n"); + const hidden = raw + .split("\n") + .filter( + (line, index) => + blanked[index] === "" && + line.trim() !== "" && + !/^\s*(?:\/\/|\/\*|\*)/u.test(line) + ); + expect(hidden).toEqual([]); + }); +}); diff --git a/test/fixtures/catalogs.ts b/test/support/catalogs.ts similarity index 81% rename from test/fixtures/catalogs.ts rename to test/support/catalogs.ts index 673a906a..40e2bc5f 100644 --- a/test/fixtures/catalogs.ts +++ b/test/support/catalogs.ts @@ -17,10 +17,14 @@ * The prices DO disagree for two models, which is why the pricing decision was * testable against the real catalog and the others were not. * - * @module test/fixtures/catalogs + * It reaches `src` through the one door, `./src.ts`, like the other fixtures, + * so a module move edits one file. (The setup file cannot: it loads in the + * jsdom files too, so it names the defining modules directly.) + * + * @module test/support/catalogs */ -import type { CatalogModelSpec } from "../../src/catalog-data.ts"; +import { type CatalogModelSpec, refreshCatalog } from "./src.ts"; /** What a test says about one catalogue row, in the terms the decisions use. */ export interface SpecInput { @@ -168,3 +172,34 @@ export const payload = (world: { }); return { "opencode-go": provider(world.go), opencode: provider(world.zen) }; }; + +/** Free on Zen, paid on Go. Neither probe id contains "free", so only the row decides. */ +export const ZEN_FREE_PROBE = "plane-probe"; + +/** Free on Go, paid on Zen. */ +export const GO_FREE_PROBE = "go-probe"; + +/** + * Refresh the live catalogue with the two plane-divergent probes. + * + * A request-path test needs the world installed where the shipped lookup reads + * it, so this goes through `refreshCatalog` (the stubbed fetch never touches the + * network). `refreshCatalog` merges into the live maps, and + * `test/support/reset-process-state.ts` restores the shim after every test, so + * a case seeds its own world. + * + * @returns once the live maps carry both probes. + */ +export const seedDivergentCatalog = async (): Promise => { + const body = payload({ + go: [ + { free: true, id: GO_FREE_PROBE, name: "Go Probe" }, + { id: ZEN_FREE_PROBE, name: "Plane Probe" }, + ], + zen: [ + { id: GO_FREE_PROBE, name: "Go Probe" }, + { free: true, id: ZEN_FREE_PROBE, name: "Plane Probe" }, + ], + }); + await refreshCatalog(async () => Response.json(body), true); +}; diff --git a/test/support/host-versions.ts b/test/support/host-versions.ts new file mode 100644 index 00000000..55ac4fba --- /dev/null +++ b/test/support/host-versions.ts @@ -0,0 +1,212 @@ +/** + * The hosts this plugin supports, named once: the latest release of BOTH + * harness channels, as each one actually ships (D14'', D22''). + * + * - `rc` is `@deepseek-ai/dsh@latest`; `alpha` is `@deepseek-ai/dsh@alpha`. + * Each record names the `@deepseek-ai` cluster's tag, cordis and its loader + * (each on its own line, at what the cluster's `~` range resolves), and the + * `@deepseek-ai/schemastery` the cluster's range resolves. + * - `piRange` is the range the bridge (`dsh-llm-pi-ai`) declares. Nothing in + * the published packages narrows it: no shrinkwrap, no bundled copy, no + * install script and no runtime version check, so an npm install runs pi + * UNPATCHED at the newest 1.x the range admits. That version is `pi`, and the + * channel's committed lockfile pins it (`lockfile`). + * - `piFloor` is the range's floor, which is also the pi the harness's own + * workspace pins at the channel's tag (and patches; only the desktop app + * ships that copy). `scripts/use-channel.ts --floor` installs it, + * unpatched, for a local run; it is never committed. + * + * This repository carries no pi patch: the harness's patch touches only + * streamed tool-call arguments and thought signatures, which no `src` module + * reads (`test/architecture.test.ts` holds that), and a pnpm patch is keyed to + * one exact version, so carrying it would break every pi bump. + * + * Which install is under test is `selectedHost()`: `DSH_CHANNEL` (and + * `DSH_PI=floor`) when set, else the marker `scripts/use-channel.ts` leaves in + * `node_modules` while the install it describes is still the one on disk, else + * the root lockfile's channel, alpha. `test/host-versions.test.ts` checks the + * installed tree against that record; `test/harness-channel.test.ts` checks + * the declarations and holds each committed lockfile to its own record. + * + * @module test/support/host-versions + */ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** The two harness distribution channels, by their npm dist-tags' role. */ +export type Channel = "alpha" | "rc"; + +export interface ChannelRecord { + /** `@deepseek-ai/cordis`, what the cluster's `~` range resolves. */ + readonly cordis: string; + /** The npm dist-tag the channel publishes `@deepseek-ai/dsh` under. */ + readonly distTag: "alpha" | "latest"; + /** The `@deepseek-ai/dsh-*` cluster's version. */ + readonly dsh: string; + /** `@deepseek-ai/cordis-plugin-loader`, what the cluster's range resolves. */ + readonly loader: string; + /** The committed lockfile that pins this channel, from the repo root. */ + readonly lockfile: string; + /** The newest pi `piRange` resolves: what an npm install runs today. */ + readonly pi: string; + /** `piRange`'s floor: the pi the harness's own workspace pins at the tag. */ + readonly piFloor: string; + /** The pi range the bridge, `dsh-llm-pi-ai`, declares. */ + readonly piRange: string; + /** `@deepseek-ai/schemastery`, what the cluster's `~` range resolves. */ + readonly schemastery: string; +} + +export const CHANNELS: Readonly> = { + alpha: { + cordis: "4.0.6-alpha.1", + distTag: "alpha", + dsh: "0.2.2-alpha.2", + loader: "1.0.7-alpha.1", + lockfile: "pnpm-lock.yaml", + pi: "1.5.0", + piFloor: "1.3.0", + piRange: "^1.3.0", + schemastery: "3.18.6-alpha.1", + }, + rc: { + cordis: "4.0.5", + distTag: "latest", + dsh: "0.2.1-rc.5", + loader: "1.0.6", + lockfile: "channels/rc/pnpm-lock.yaml", + pi: "1.5.0", + piFloor: "1.1.0", + piRange: "^1.1.0", + schemastery: "3.18.5", + }, +}; + +/** The channel the root `package.json` and `pnpm-lock.yaml` install. */ +export const DEFAULT_CHANNEL: Channel = "alpha"; + +/** The pi range the plugin itself may declare: the union of both channels'. */ +export const PI_UNION_RANGE = "^1.1.0"; + +/** Where `scripts/use-channel.ts` records what it installed. */ +export const MARKER = join("node_modules", ".dsh-channel.json"); + +/** The copy of the lockfile pnpm keeps for what is actually installed. */ +const INSTALLED_LOCK = join("node_modules", ".pnpm", "lock.yaml"); + +export interface SelectedHost extends ChannelRecord { + readonly channel: Channel; + /** Whether pi sits at the range's floor rather than its newest version. */ + readonly floor: boolean; + /** The pi this install runs: `piFloor` on a floor install, else `pi`. */ + readonly piInstalled: string; + /** Where the selection came from, for a failure message. */ + readonly source: "default" | "env" | "marker"; +} + +export interface Marker { + readonly channel: Channel; + readonly floor: boolean; + /** sha256 of `node_modules/.pnpm/lock.yaml` right after the switch. */ + readonly installed: string; +} + +export const isChannel = (value: unknown): value is Channel => + value === "alpha" || value === "rc"; + +/** sha256 of the installed-lockfile copy, or undefined when there is none. */ +export const installedLockHash = (root: string): string | undefined => { + try { + return createHash("sha256") + .update(readFileSync(join(root, INSTALLED_LOCK))) + .digest("hex"); + } catch { + return undefined; + } +}; + +const readMarker = (root: string): Marker | undefined => { + try { + const marker = JSON.parse( + readFileSync(join(root, MARKER), "utf8") + ) as Partial; + if ( + isChannel(marker.channel) && + typeof marker.floor === "boolean" && + typeof marker.installed === "string" + ) { + return marker as Marker; + } + } catch { + // No marker: the root install. + } + return undefined; +}; + +const select = ( + channel: Channel, + floor: boolean, + source: SelectedHost["source"] +): SelectedHost => { + const record = CHANNELS[channel]; + return { + ...record, + channel, + floor, + piInstalled: floor ? record.piFloor : record.pi, + source, + }; +}; + +/** + * The host record the install under test is expected to match. + * + * A pure function of the environment and the tree it is handed, so a test can + * drive every branch. A marker whose install hash no longer matches the tree + * is ignored: a plain `pnpm install` since the switch put the root channel + * back, and the marker would otherwise name a channel that is not installed. + */ +export const selectedHost = ( + env: Readonly> = process.env, + root: string = process.cwd() +): SelectedHost => { + const fromEnv = env.DSH_CHANNEL; + if (fromEnv !== undefined && fromEnv !== "") { + if (!isChannel(fromEnv)) { + throw new Error( + `DSH_CHANNEL=${fromEnv} names no channel; use one of ${Object.keys(CHANNELS).join(", ")}` + ); + } + return select(fromEnv, env.DSH_PI === "floor", "env"); + } + const marker = readMarker(root); + if (marker !== undefined && marker.installed === installedLockHash(root)) { + return select(marker.channel, marker.floor, "marker"); + } + return select(DEFAULT_CHANNEL, false, "default"); +}; + +/** + * The selected install's versions in the shape the suite reads them: `pi` is + * the pi this install runs (`piInstalled`). Resolved once, when the module + * loads, from the process's own environment and working directory. + */ +export const HOST_VERSIONS = ((): { + readonly channel: Channel; + readonly cordis: string; + readonly dsh: string; + readonly floor: boolean; + readonly loader: string; + readonly pi: string; +} => { + const host = selectedHost(); + return { + channel: host.channel, + cordis: host.cordis, + dsh: host.dsh, + floor: host.floor, + loader: host.loader, + pi: host.piInstalled, + }; +})(); diff --git a/test/support/node-engine.ts b/test/support/node-engine.ts new file mode 100644 index 00000000..6e0cfd1c --- /dev/null +++ b/test/support/node-engine.ts @@ -0,0 +1,75 @@ +/** + * Refuse to run the suite on a Node the package does not support. + * + * `package.json` declares `engines.node`, and several cases depend on what that + * floor guarantees: `node:sqlite` unflagged, `Intl.DurationFormat`, and ICU data + * new enough to agree with the strings the tests pin. Below the floor those cases + * do not fail for a reason anyone could act on — they fail as a bundling error in + * an unrelated jsdom file and as a unit word that differs by one character, which + * reads as a regression in the code under test. + * + * So the run stops before collecting a single file and names the real cause. + * The floor is read from `package.json`, not restated, so raising it there is the + * whole of raising it here. + * + * @module test/support/node-engine + */ +import { readFileSync } from "node:fs"; + +/** + * The lowest major a `>=N` range admits, or `undefined` for any other shape. + * + * Deliberately narrow: `engines.node` is a single `>=` floor in this package, + * and a range this cannot read is reported rather than guessed at. + * + * @param range - the `engines.node` value. + * @returns the floor's major version. + */ +export const floorMajorOf = (range: unknown): number | undefined => { + if (typeof range !== "string") { + return undefined; + } + const match = /^\s*>=\s*(\d+)(?:\.\d+){0,2}\s*$/u.exec(range); + return match?.[1] === undefined ? undefined : Number(match[1]); +}; + +/** + * Why this Node cannot run the suite, or `undefined` when it can. + * + * @param version - `process.versions.node`. + * @param range - `engines.node` from `package.json`. + * @returns a message naming the version found and the floor required. + */ +export const engineRefusal = ( + version: string, + range: unknown +): string | undefined => { + const floor = floorMajorOf(range); + if (floor === undefined) { + return `engines.node is ${JSON.stringify(range)}, which this guard cannot read; expected a single ">=N" floor`; + } + const major = Number(version.split(".")[0]); + if (!Number.isFinite(major) || major < floor) { + return ( + `this suite needs Node ${String(range)} (package.json engines.node) and is running on ${version}. ` + + "Below the floor, cases fail for engine reasons — a missing node:sqlite or Intl.DurationFormat — " + + "that read like regressions. Switch Node (the version CI uses is in .node-version) and re-run." + ); + } + return undefined; +}; + +/** Vitest `globalSetup`: runs once, before any test file is collected. */ +export default function setup(): void { + const pkg: unknown = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8") + ); + const range = + typeof pkg === "object" && pkg !== null + ? (pkg as { engines?: { node?: unknown } }).engines?.node + : undefined; + const refusal = engineRefusal(process.versions.node, range); + if (refusal !== undefined) { + throw new Error(refusal); + } +} diff --git a/test/support/offline.ts b/test/support/offline.ts new file mode 100644 index 00000000..8b9070db --- /dev/null +++ b/test/support/offline.ts @@ -0,0 +1,54 @@ +/** + * Keep the unit and mock suites off the network. + * + * Both are documented as offline, and they were not: every `getLive*Catalog` + * read on a stale catalog — which is every read in a fresh test process — + * starts a background revalidation through `globalThis.fetch`. Measured: 54 + * requests to `models.opencode.ai` per unit run. Where the runner has network, + * one that lands mid-test replaces the catalog under the case reading it, so + * a result depended on what the vendor published that minute. + * + * A request to anything but loopback is refused here. The background refresh + * already degrades to the bundled shim on a failed fetch, so nothing else + * changes; a case that MEANS to fetch stubs `fetch` itself, and the mock suite + * talks to a loopback server, which stays allowed. The e2e config leaves this + * file out, because reaching the gateway is its whole point. + * + * @module test/support/offline + */ +const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +/** The host a fetch input targets, or `""` when it is not an absolute URL. */ +export const hostOf = (input: RequestInfo | URL): string => { + let url: string; + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.href; + } else { + ({ url } = input); + } + try { + return new URL(url).hostname; + } catch { + return ""; + } +}; + +const online = globalThis.fetch; + +/** The guard, exported so a test can check it refuses what it should. */ +export const offlineFetch = async ( + input: RequestInfo | URL, + init?: RequestInit +): Promise => { + const host = hostOf(input); + if (!LOOPBACK.has(host)) { + throw new TypeError( + `offline test suite: refused a request to ${host || "a non-URL input"}; stub fetch in the case that needs it` + ); + } + return online(input, init); +}; + +globalThis.fetch = offlineFetch as typeof fetch; diff --git a/test/support/opencode-store-fixture.ts b/test/support/opencode-store-fixture.ts new file mode 100644 index 00000000..684f5311 --- /dev/null +++ b/test/support/opencode-store-fixture.ts @@ -0,0 +1,131 @@ +/** + * A real OpenCode credential database, for the host-side store readers. + * + * Kept out of `test-helpers.ts` on purpose: that module is shared with the + * jsdom (client) tests, and a `node:sqlite` import there drags a host-only + * built-in into every client test's module graph. The split is the same one + * `src/` keeps between the store reader and the client bundle. + * + * @module test/support/opencode-store-fixture + */ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import nodePath from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +/** One row of the installation's `credential` table, as a fixture. */ +export interface FixtureCredential { + /** The integration the credential belongs to: `opencode`, `opencode-go`, … */ + integrationId: string; + /** The credential's own name; defaults to the integration id. */ + label?: string; + /** The secret. A fixture shaped like one, never a real credential. */ + value: string; + /** `1` for the credential in use, `0` for one the user already replaced. */ + active?: number; +} + +/** One row of the installation's `account` table, plus the state that owns it. */ +export interface FixtureAccount { + /** The access token. A fixture shaped like one, never a real credential. */ + accessToken: string; + /** The account's email; not a secret, and not required. */ + email?: string; + /** The org the account is active in, written to `account_state`. */ + orgId?: string; + /** The refresh token, when the fixture should carry one. */ + refreshToken?: string; + /** + * The stored expiry, in epoch milliseconds. + * + * Deliberately a knob: the plugin uses a stored account whatever this says, + * and a fixture has to be able to put it in the past to prove that. + */ + tokenExpiry?: number; +} + +/** A credential store the test owns, and the way to point the reader at it. */ +export interface FixtureStore { + /** Set this as `$XDG_DATA_HOME`: the reader resolves its path from it. */ + root: string; + /** Remove the whole root. Call from `afterEach`. */ + cleanup: () => void; +} + +/** + * Build an OpenCode credential database in a temporary XDG data root. + * + * The reader resolves its own path from the environment, so a fixture is not a + * mock: this writes the real schema to a real SQLite file and hands back the + * root to export, which is the only thing that makes the store this test's + * rather than whatever the machine happens to have installed. + * + * @param credentials - the rows to insert, in order. + * @param account - the stored login, when the fixture should have one. + * @returns the data root and its cleanup. + */ +export const createOpencodeStore = ( + credentials: readonly FixtureCredential[], + account?: FixtureAccount +): FixtureStore => { + const root = mkdtempSync( + nodePath.join(os.tmpdir(), "opencode-store-fixture-") + ); + const directory = nodePath.join(root, "opencode"); + mkdirSync(directory, { recursive: true }); + const database = new DatabaseSync(nodePath.join(directory, "opencode.db")); + database.exec(` + CREATE TABLE credential ( + id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, + connector_id TEXT, method_id TEXT, active INTEGER, + time_created INTEGER, time_updated INTEGER + ); + CREATE TABLE account ( + id TEXT PRIMARY KEY, email TEXT, url TEXT, access_token TEXT, + refresh_token TEXT, token_expiry INTEGER, + time_created INTEGER, time_updated INTEGER + ); + CREATE TABLE account_state ( + id INTEGER PRIMARY KEY, active_account_id TEXT, active_org_id TEXT + ); + `); + const insert = database.prepare( + "INSERT INTO credential (id, integration_id, label, value, active) VALUES (?, ?, ?, ?, ?)" + ); + for (const [index, credential] of credentials.entries()) { + insert.run( + `fixture-${index}`, + credential.integrationId, + credential.label ?? credential.integrationId, + credential.value, + credential.active ?? 1 + ); + } + if (account !== undefined) { + database + .prepare( + "INSERT INTO account (id, email, url, access_token, refresh_token, token_expiry) VALUES (?, ?, ?, ?, ?, ?)" + ) + .run( + "fixture-account", + account.email ?? "you@example.com", + "https://opencode.ai/console", + account.accessToken, + account.refreshToken ?? "", + account.tokenExpiry ?? null + ); + // The org lives in its own table, and only the active row is read. + database + .prepare( + "INSERT INTO account_state (id, active_account_id, active_org_id) VALUES (?, ?, ?)" + ) + .run(1, "fixture-account", account.orgId ?? null); + } + database.close(); + return { + cleanup: () => { + rmSync(root, { force: true, recursive: true }); + }, + root, + }; +}; diff --git a/test/support/reset-process-state.ts b/test/support/reset-process-state.ts new file mode 100644 index 00000000..781347b1 --- /dev/null +++ b/test/support/reset-process-state.ts @@ -0,0 +1,30 @@ +/** + * Reset the plugin's process-wide stores after every test. + * + * Vitest isolates FILES, not the tests inside one, and the plugin keeps three + * stores at module level for the life of the process: the keys the fetch patch + * captured, the per-session spend, and the live catalog. A case that leaves any + * of them behind makes the next case's answer depend on run order — a captured + * key turns a "no credential" case into a "credential resolved" one, a recorded + * session becomes the meter's fallback reading, and a fixture refresh changes + * every catalog count after it. Measured: with the suite shuffled, seeds 42 and + * 1234 failed three catalog cases on exactly that. + * + * `pnpm run test:shuffle` runs the suite in a random order to keep it that way. + * + * Imported through the same specifiers the tests use, so it clears the same + * module instances they read. + * + * @module test/support/reset-process-state + */ +import { afterEach } from "vitest"; + +import { clearCapturedApiKeys } from "../../src/key-capture.ts"; +import { resetCatalogState } from "../../src/models-catalog.ts"; +import { clearSessionUsageStore } from "../../src/session-cost.ts"; + +afterEach(() => { + clearCapturedApiKeys(); + clearSessionUsageStore(); + resetCatalogState(); +}); diff --git a/test/support/src.ts b/test/support/src.ts new file mode 100644 index 00000000..42380256 --- /dev/null +++ b/test/support/src.ts @@ -0,0 +1,456 @@ +/** + * The single indirection through which tests reach `src`. + * + * The package's public barrel is the host's contract — `apply`, `Config`, + * `inject`, `name` and the config types — and nothing else, and P10a + * moves modules into subject folders. A test that names a `src` path breaks on + * every such move, so this file is the one place that knows where things live: + * each name is re-exported from the module that DEFINES it, never through the + * public barrel. `test/architecture.test.ts` enforces both halves. + * + * What keeps its direct path, because the path is its subject or the door + * would load too much: + * + * - a `vi.mock("../src/x.ts")` target and its `importOriginal` type (vitest mocks by module id); + * - a fresh-module re-import after `vi.resetModules()` (`purge-service.test.ts`); + * - the architecture and public-API tests, which are about the `src` layout + * and the barrel themselves; + * - the jsdom (client) tests, because this file re-exports the host graph + * (`node:sqlite`, pi, `lifecycle.ts`) and that must stay out of a client + * test; + * - the setup file `test/support/reset-process-state.ts`, which runs before + * every test, jsdom ones included, for the same reason; the other setup + * file, `offline.ts`, touches no `src` module and is walked like any test. + * + * Importing this file loads the whole `src` graph, host and client, so a module + * that fails to load fails every test that comes through here, not only its own. + * + * Two names are defined twice under one name, so the second definition is + * aliased: the client bundle's `apply` (`applySettingsPage`) and the Go + * allowance formatter `formatUsd` (`formatAllowanceUsd`). + * + * The file is type-checked with the rest of `test/`, so `vp check` proves that + * every re-export resolves. + * + * @module test/support/src + */ + +// attachment-services.ts +export { attachmentResolvers } from "../../src/attachment-services.ts"; + +// catalog-data.ts +export { + OPENCODE_FAMILY_API, + OPENCODE_GO_CATALOG, + OPENCODE_PLANE_API, + OPENCODE_ZEN_CATALOG, +} from "../../src/catalog-data.ts"; +export type { CatalogModelSpec } from "../../src/catalog-data.ts"; + +// config-values.ts +export { + ALL_MODELS_MARKER, + DEFAULT_ENDPOINT_FAMILY, + DEFAULT_KEY_SOURCE, + DEFAULT_PROVIDERS, + ENDPOINT_FAMILIES, + KEY_SOURCE_POLICIES, + readBoolean, + readString, + readStringList, + unwrapNode, +} from "../../src/config-values.ts"; + +// config.ts +export { + Config, + CONFIG_DEFAULTS, + DEFAULT_FREE_MODEL_MARKER, + DEFAULT_USAGE_BASE_URL, + DEFAULT_USAGE_KEY_ENV, + resolveConfig, +} from "../../src/config.ts"; +export type { + ModelOverrideConfig, + PluginConfig, + ResolvedPluginConfig, +} from "../../src/config.ts"; + +// cordis-context.ts +export { + loaderOfContext, + readCredentialsResolver, + readEntryOptions, + readSessionMetaResolver, +} from "../../src/cordis-context.ts"; +export type { CordisContext } from "../../src/cordis-context.ts"; + +// debug.ts +export { recordDebug } from "../../src/debug.ts"; + +// fetch-patch.ts +export { + hasSessionHeader, + isOpenCodeRequest, + patchFetch, +} from "../../src/fetch-patch.ts"; +export type { FetchPatchHooks } from "../../src/fetch-patch.ts"; + +// go-discovery.ts +export { + declaredProviderModels, + discoverGoConfig, + effectiveGoKeyRef, + resolveGoApiKey, + resolveGoBaseURL, + resolveGoKeyForRef, + resolveOpencodeStoreKey, + resolveZenCreditInfo, + toGoBaseURL, +} from "../../src/go-discovery.ts"; +export type { DiscoveredGoConfig } from "../../src/go-discovery.ts"; + +// go-limits-data.ts +export { GO_MODEL_LIMITS } from "../../src/go-limits-data.ts"; + +// go-limits.ts +export { + allowanceFor, + formatUsd as formatAllowanceUsd, + GO_PLAN_TIERS, + normalizeModelLabel, + parseGoLimitsDoc, + WINDOW_SHARE, +} from "../../src/go-limits.ts"; + +// google-bridge.ts +export { + ambientAuthContext, + emptyCredentialStore, + GO_GOOGLE_BASE_URL, + googleProviderFor, + loadHostPi, + registerGoogleBridge, + resolveGatewayKey, +} from "../../src/google-bridge.ts"; +export type { GoogleBridgeInputs, PiRuntime } from "../../src/google-bridge.ts"; + +// identity.ts +export { + inject, + name, + OPENCODE_PATCH_USER_AGENT, + OPENCODE_USER_AGENT, +} from "../../src/identity.ts"; + +// key-capture.ts +export { + clearCapturedApiKeys, + extractApiKeyFromHeaders, + getCapturedApiKey, + isPlaceholderApiKey, + recordCapturedApiKey, + tierForRequest, +} from "../../src/key-capture.ts"; + +// lifecycle.ts +export { apply, mountInternalRoutes } from "../../src/lifecycle.ts"; + +// models-catalog.ts +export { + catalogPlaneForRoute, + enrichModelsResponse, + findModelSpec, + findModelSpecOn, + getLiveGoCatalog, + getLiveZenCatalog, + isDeprecatedModelOn, + isGoModelsListingUrl, + isModelsListingUrl, + parseModelsDevCatalog, + refreshCatalog, + resetCatalogState, + sanitizeModalities, +} from "../../src/models-catalog.ts"; + +// models-discovery.ts +export { + decorateModelDiscovery, + decorateModelInfo, + decoratePlaneAdapters, + hideResponsesRoute, + isRouteRegistered, + isServableModel, + mergeDiscoveredModels, + PI_THINKING_LEVELS, + resolveDiscoveryProvider, + resolveEffectiveContextWindow, +} from "../../src/models-discovery.ts"; + +// opencode-paths.ts +export { + opencodeDatabaseFile, + opencodeDatabasePath, + opencodeRoots, +} from "../../src/opencode-paths.ts"; + +// opencode-store-service.ts +export { OpencodeStoreService } from "../../src/opencode-store-service.ts"; + +// opencode-store.ts +export { + inspectOpencodeStore, + readOpencodeCredentials, + summarizeCredential, +} from "../../src/opencode-store.ts"; + +// pi-provider.ts +export { + BASE_FOR_PROTOCOL, + baseForProtocol, + LEGACY_PLANE_API, + piModelFor, + planeBaseFor, + protocolFor, + thinkingLevelMapFor, +} from "../../src/pi-provider.ts"; + +// purge-contract.ts +export { parsePurgeResult } from "../../src/purge-contract.ts"; + +// purge-service.ts +export { + ModelPurgeService, + stripRetiredReferences, +} from "../../src/purge-service.ts"; + +// responses-provider.ts +export { + inheritedCredentialRef, + loadPiAi, + loadPiAiAdapterHost, + loadPiAiModule, + modelsForSdk, + narrowToDeclared, + reasoningEffortsFor, + registerResponsesProvider, + undescribedDeclaredModels, + unroutedDeclaredModels, +} from "../../src/responses-provider.ts"; + +// responses-routes.ts +export { + ANTHROPIC_ROUTE, + ANTHROPIC_SDK, + GO_GOOGLE_INTERNAL_ROUTE, + GO_RESPONSES_ROUTE, + GOOGLE_INTERNAL_ROUTE, + GOOGLE_SDK, + INTERNAL_ROUTES, + internalRouteFor, + isServableSdk, + MISTRAL_SDK, + PROTOCOL_FOR_SDK, + RESPONSES_ROUTE, + RESPONSES_SDK, + ROUTE_FOR_PLANE_PROTOCOL, + ROUTE_FOR_PROTOCOL, + SELF_MOUNTED_ROUTES, + UNSERVED_PLANE_SDKS, + UNSERVED_SDKS, +} from "../../src/responses-routes.ts"; + +// session-cost.ts +export { + calculateTurnCost, + clearSessionUsageStore, + describeModel, + formatModelRate, + formatUsd, + getSessionUsage, + recordTurnUsage, +} from "../../src/session-cost.ts"; +export type { + ModelCostRate, + SessionUsageSnapshot, +} from "../../src/session-cost.ts"; + +// session.ts +export { + fallbackSessionId, + headerValueFor, + newOpenCodeMessageId, + OPENCODE_UA, + openCodeSessionIdFor, +} from "../../src/session.ts"; + +// settings-boolean-field.tsx +export { SettingsBooleanField } from "../../src/settings-boolean-field.tsx"; +export type { BooleanFieldProps } from "../../src/settings-boolean-field.tsx"; + +// settings-card.tsx +export { OpencodeCard, purgeLine } from "../../src/settings-card.tsx"; + +// settings-choice-field.tsx +export { SettingsChoiceField } from "../../src/settings-choice-field.tsx"; +export type { ChoiceFieldProps } from "../../src/settings-choice-field.tsx"; + +// settings-copy.ts +export { en, withBundleCopy, zh } from "../../src/settings-copy.ts"; + +// settings-field-shell.tsx +export { SettingsFieldShell } from "../../src/settings-field-shell.tsx"; +export type { FieldShellProps } from "../../src/settings-field-shell.tsx"; + +// settings-fields.ts +export { + CARD_FIELDS, + CONFIG_ONLY_FIELDS, + SPECS, +} from "../../src/settings-fields.ts"; + +// settings-page.tsx +export { + apply as applySettingsPage, + LEGACY_NS, + NS, + patchRemote, + PKG, + SCOPED_PKG, +} from "../../src/settings-page.tsx"; + +// settings-purge.ts +export { purgeDeprecatedModels } from "../../src/settings-purge.ts"; + +// settings-remote.ts +export { + callUsageRead, + remoteNamespace, + remoteNamespaceOf, +} from "../../src/settings-remote.ts"; + +// settings-store-inspect.tsx +export { + inspectLine, + StoreInspectRow, +} from "../../src/settings-store-inspect.tsx"; + +// settings-store.ts +export { inspectStore } from "../../src/settings-store.ts"; +export type { StoreInspectOutcome } from "../../src/settings-store.ts"; + +// settings-usage.tsx +export { + readGoUsage, + readUntilAnswered, + UsageSummary, + UsageSummaryView, +} from "../../src/settings-usage.tsx"; + +// stream-hook.ts +export { createStreamHook, projectNameFrom } from "../../src/stream-hook.ts"; + +// tool-fallback.ts +export { + coreToolDecision, + DUMMY_BASH_TOOL, + DUMMY_BASH_TOOL_FUNCTION, + DUMMY_READ_TOOL, + DUMMY_READ_TOOL_FUNCTION, + isCoreToolModel, + maybeInjectCoreTools, + RESPONSES_PATH, +} from "../../src/tool-fallback.ts"; + +// turn-store.ts +export { withStore } from "../../src/turn-store.ts"; +export type { ActiveTurnState } from "../../src/turn-store.ts"; + +// usage-contract.ts +export { + parseAccountRef, + parseGoUsage, + parseUsageQuery, +} from "../../src/usage-contract.ts"; +export type { GoUsage } from "../../src/usage-contract.ts"; + +// usage-panel.tsx +export { UsagePanel, UsageTrigger } from "../../src/usage-panel.tsx"; +export type { + UsagePanelProps, + UsageTriggerProps, +} from "../../src/usage-panel.tsx"; + +// usage-pill.tsx +export { MIN_FEEDBACK_MS, UsagePill } from "../../src/usage-pill.tsx"; +export type { + ModelDirectoryState, + SnapshotStore, +} from "../../src/usage-pill.tsx"; + +// usage-ui.ts +export { + CIRCUMFERENCE, + CONSOLE_URL, + describeUsage, + formatRelativeReset, + getAffectingWindow, + getWindowColorFor, + GO_PLAN_URL, + matchesAny, + parseFailure, + resetLabel, + ringGeometry, + STYLES, +} from "../../src/usage-ui.ts"; +export type { RelativeReset } from "../../src/usage-ui.ts"; + +// usage.ts +export { GoUsageService } from "../../src/usage.ts"; + +// utils/google-replay.ts +export { + googleHistoryFor, + googleReplayFor, + googleReplayStream, +} from "../../src/utils/google-replay.ts"; + +// utils/guards.ts +export { + getAsyncIterator, + isAsyncIterableLike, + isAsyncIteratorLike, + isFetchFunction, + isFunctionLike, + isRecord, + isUnknownArray, +} from "../../src/utils/guards.ts"; + +// utils/request-shape.ts +export { + ANTHROPIC_VERSION, + authHeaderFor, + gatewayPlaneFor, + headerSourceOf, + isGoPlaneUrl, + isZenProvider, + modelIdOf, + requestUrlOf, + toolNamesOf, +} from "../../src/utils/request-shape.ts"; + +// utils/retry.ts +export { backoffOver, retryUntilAnswered } from "../../src/utils/retry.ts"; +export type { RetryOptions, RetryTimer } from "../../src/utils/retry.ts"; + +// utils/session-id.ts +export { + isOpenCodeMessageId, + isOpenCodeSessionId, + PARENT_SESSION_ALT_HEADER, + PARENT_SESSION_HEADER, + SESSION_AFFINITY_HEADER, + SESSION_HEADER, + sessionIdForRequest, +} from "../../src/utils/session-id.ts"; diff --git a/test/support/strip-comments.ts b/test/support/strip-comments.ts new file mode 100644 index 00000000..835f062f --- /dev/null +++ b/test/support/strip-comments.ts @@ -0,0 +1,70 @@ +/** + * Comment blanking for the source-reading gates, one line at a time. + * + * The gates used to strip block comments with a regex over the whole file, + * `/\/\*[\s\S]*?\*\//`, before the line comments. That regex cannot see + * string literals or `//` comments, so a glob such as `"locale/*.json"` opened + * a fake block that ran to the next `*\/` and blanked the code in between — + * seventy lines of one test file, silently. A gate that misses code is worse + * than one that flags a comment, so this reader is built to err loudly: + * + * - a line whose first non-blank text is `//` is blanked; + * - a block comment is recognised only where it OPENS a line, and runs to the + * first `*\/`; whatever follows that `*\/` on its closing line is read the + * same way, so it may be code, a `//` comment, or another block — one that + * may itself run on to later lines; + * - a `/*` anywhere else — in a string, after code, in a JSX `{/* … *\/}` — is + * left as text, so at worst a comment's words are read as code; + * - a block still open at the end of the source throws: an unterminated block + * comment is a syntax error in real TypeScript, so its `/*` was not a + * comment, and the gate fails rather than passing on a blanked file. + * + * One blind spot remains, and it is narrow: a line INSIDE a multi-line string + * or template literal that begins with `/*` and is closed by a later real + * `*\/` is blanked as a comment, along with the lines between. The CSS + * comments inside `src/usage-ui.ts`'s template literal are that shape, and are + * harmless because they are comments. + * + * Line breaks are kept, so a `file:line` computed on the result is true. + * + * @module test/support/strip-comments + */ + +/** `source` with its comment lines blanked and its line count unchanged. */ +export const blankCommentLines = (source: string): string => { + let inBlock = false; + let openedAt = 0; + // One line, or the tail of one after a `*/`, read with the block state the + // whole source shares, so a block that opens in a tail runs on to the next + // lines. + const blankLine = (text: string, lineNumber: number): string => { + let rest = text; + if (!inBlock) { + const trimmed = rest.trimStart(); + if (trimmed.startsWith("//")) { + return ""; + } + if (!trimmed.startsWith("/*")) { + return rest; + } + inBlock = true; + openedAt = lineNumber; + rest = trimmed.slice(2); + } + const close = rest.indexOf("*/"); + if (close === -1) { + return ""; + } + inBlock = false; + return blankLine(rest.slice(close + 2), lineNumber); + }; + const lines = source + .split("\n") + .map((line, index) => blankLine(line, index + 1)); + if (inBlock) { + throw new Error( + `blankCommentLines: the block comment opened on line ${openedAt} never closes, so its \`/*\` is not a comment (a string or template literal?)` + ); + } + return lines.join("\n"); +}; diff --git a/test/test-helpers.ts b/test/test-helpers.ts index ca421970..abd62dab 100644 --- a/test/test-helpers.ts +++ b/test/test-helpers.ts @@ -6,14 +6,10 @@ import assert from "node:assert/strict"; import { AsyncLocalStorage } from "node:async_hooks"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import os from "node:os"; -import nodePath from "node:path"; -import { DatabaseSync } from "node:sqlite"; import { setTimeout as sleep } from "node:timers/promises"; -import type { ActiveTurnState } from "../src/index.ts"; +import type { ActiveTurnState } from "./support/src.ts"; export const SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; @@ -178,125 +174,6 @@ export const createMockContext = () => reflect: { provide: () => disposeNoop }, }) as never; -/* ------------------------------------------------- OpenCode credential store */ - -/** One row of the installation's `credential` table, as a fixture. */ -export interface FixtureCredential { - /** The integration the credential belongs to: `opencode`, `opencode-go`, … */ - integrationId: string; - /** The credential's own name; defaults to the integration id. */ - label?: string; - /** The secret. A fixture shaped like one, never a real credential. */ - value: string; - /** `1` for the credential in use, `0` for one the user already replaced. */ - active?: number; -} - -/** One row of the installation's `account` table, plus the state that owns it. */ -export interface FixtureAccount { - /** The access token. A fixture shaped like one, never a real credential. */ - accessToken: string; - /** The account's email; not a secret, and not required. */ - email?: string; - /** The org the account is active in, written to `account_state`. */ - orgId?: string; - /** The refresh token, when the fixture should carry one. */ - refreshToken?: string; - /** - * The stored expiry, in epoch milliseconds. - * - * Deliberately a knob: the plugin uses a stored account whatever this says, - * and a fixture has to be able to put it in the past to prove that. - */ - tokenExpiry?: number; -} - -/** A credential store the test owns, and the way to point the reader at it. */ -export interface FixtureStore { - /** Set this as `$XDG_DATA_HOME`: the reader resolves its path from it. */ - root: string; - /** Remove the whole root. Call from `afterEach`. */ - cleanup: () => void; -} - -/** - * Build an OpenCode credential database in a temporary XDG data root. - * - * The reader resolves its own path from the environment, so a fixture is not a - * mock: this writes the real schema to a real SQLite file and hands back the - * root to export, which is the only thing that makes the store this test's - * rather than whatever the machine happens to have installed. - * - * @param credentials - the rows to insert, in order. - * @param account - the stored login, when the fixture should have one. - * @returns the data root and its cleanup. - */ -export const createOpencodeStore = ( - credentials: readonly FixtureCredential[], - account?: FixtureAccount -): FixtureStore => { - const root = mkdtempSync( - nodePath.join(os.tmpdir(), "opencode-store-fixture-") - ); - const directory = nodePath.join(root, "opencode"); - mkdirSync(directory, { recursive: true }); - const database = new DatabaseSync(nodePath.join(directory, "opencode.db")); - database.exec(` - CREATE TABLE credential ( - id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, - connector_id TEXT, method_id TEXT, active INTEGER, - time_created INTEGER, time_updated INTEGER - ); - CREATE TABLE account ( - id TEXT PRIMARY KEY, email TEXT, url TEXT, access_token TEXT, - refresh_token TEXT, token_expiry INTEGER, - time_created INTEGER, time_updated INTEGER - ); - CREATE TABLE account_state ( - id INTEGER PRIMARY KEY, active_account_id TEXT, active_org_id TEXT - ); - `); - const insert = database.prepare( - "INSERT INTO credential (id, integration_id, label, value, active) VALUES (?, ?, ?, ?, ?)" - ); - for (const [index, credential] of credentials.entries()) { - insert.run( - `fixture-${index}`, - credential.integrationId, - credential.label ?? credential.integrationId, - credential.value, - credential.active ?? 1 - ); - } - if (account !== undefined) { - database - .prepare( - "INSERT INTO account (id, email, url, access_token, refresh_token, token_expiry) VALUES (?, ?, ?, ?, ?, ?)" - ) - .run( - "fixture-account", - account.email ?? "you@example.com", - "https://opencode.ai/console", - account.accessToken, - account.refreshToken ?? "", - account.tokenExpiry ?? null - ); - // The org lives in its own table, and only the active row is read. - database - .prepare( - "INSERT INTO account_state (id, active_account_id, active_org_id) VALUES (?, ?, ?)" - ) - .run(1, "fixture-account", account.orgId ?? null); - } - database.close(); - return { - cleanup: () => { - rmSync(root, { force: true, recursive: true }); - }, - root, - }; -}; - /* ------------------------------------------------ React element tree helpers */ /** diff --git a/test/tool-fallback.test.ts b/test/tool-fallback.test.ts index 1a310ff9..d79ce118 100644 --- a/test/tool-fallback.test.ts +++ b/test/tool-fallback.test.ts @@ -17,18 +17,23 @@ import { describe, expect, it } from "vitest"; -import { ALL_MODELS_MARKER, DEFAULT_FREE_MODEL_MARKER } from "../src/index.ts"; import { + GO_FREE_PROBE, + seedDivergentCatalog, + ZEN_FREE_PROBE, +} from "./support/catalogs.ts"; +import { + ALL_MODELS_MARKER, + DEFAULT_FREE_MODEL_MARKER, DUMMY_BASH_TOOL, - DUMMY_BASH_TOOL_ANTHROPIC, DUMMY_BASH_TOOL_FUNCTION, DUMMY_READ_TOOL, - DUMMY_READ_TOOL_ANTHROPIC, DUMMY_READ_TOOL_FUNCTION, RESPONSES_PATH, + findModelSpecOn, isCoreToolModel, maybeInjectCoreTools, -} from "../src/tool-fallback.ts"; +} from "./support/src.ts"; import { parseJsonBody, toolNamesOf } from "./test-helpers.ts"; const RESPONSES_URL = `https://opencode.ai/zen/v1${RESPONSES_PATH}`; @@ -138,6 +143,23 @@ describe("maybeInjectCoreTools: bodies it must not rewrite", () => { } }); + it("leaves an Anthropic /messages body alone", () => { + // The gate admits only URLs naming `/responses` or `/completions`, and a + // gateway `/messages` URL names neither, so an Anthropic body never reaches + // the injector — which is why it carries no Anthropic schemas at all. + const headers = new Headers(); + const body = JSON.stringify({ model: FREE_MODEL }); + + const out = maybeInjectCoreTools( + "https://opencode.ai/inference/anthropic/v1/messages", + body, + headers, + options() + ); + expect(out).toBe(body); + expect(headers.get("content-length")).toBeNull(); + }); + it("injects on the completions route too", () => { const body = JSON.stringify({ input: "hi", model: FREE_MODEL }); const headers = new Headers(); @@ -459,28 +481,6 @@ describe("maybeInjectCoreTools: injection", () => { tools: [null, "bash", 7, declaredRead, DUMMY_BASH_TOOL], }); }); - - it("prefers the Anthropic schemas when the URL names /messages", () => { - // Both path segments in one URL is not a route this plugin documents: - // `RESPONSES_PATH` gates first, so a plain `/messages` URL never reaches - // the injector. The case pins the precedence between the two dialects - // (Anthropic wins over the OpenAI function form) and keeps the Anthropic - // schemas from rotting untested. - const url = "https://relay.internal/v1/messages?upstream=/responses"; - const headers = new Headers(); - const body = JSON.stringify({ model: FREE_MODEL }); - - const out = maybeInjectCoreTools(url, body, headers, options()); - - expect(parseJsonBody(bodyText(out))).toEqual({ - model: FREE_MODEL, - tools: [DUMMY_READ_TOOL_ANTHROPIC, DUMMY_BASH_TOOL_ANTHROPIC], - }); - // `input_schema`, not `parameters`: the two dialects are not - // interchangeable, and mixing them is the failure the fallback avoids. - expect(DUMMY_READ_TOOL_ANTHROPIC).toHaveProperty("input_schema"); - expect(DUMMY_READ_TOOL_FUNCTION).toHaveProperty("function"); - }); }); describe("maybeInjectCoreTools: marker scoping", () => { @@ -553,3 +553,63 @@ describe("maybeInjectCoreTools: marker scoping", () => { ]); }); }); + +describe("isCoreToolModel: the plane is the request's, with a plane-blind fallback", () => { + // The catalog's free flag is read from the plane the request is on. No id + // the shipped shim lists on both planes disagrees on it, so these cases seed + // a live catalog that does: a Go-first lookup reads the paid Go row for a + // model Zen serves free, and the free Go row for a model Zen charges for. + it("admits a model the request's plane calls free, whatever the other plane says", async () => { + await seedDivergentCatalog(); + + expect(isCoreToolModel(ZEN_FREE_PROBE, "", "zen")).toBe(true); + expect(isCoreToolModel(ZEN_FREE_PROBE, "", "go")).toBe(false); + expect(isCoreToolModel(GO_FREE_PROBE, "", "go")).toBe(true); + expect(isCoreToolModel(GO_FREE_PROBE, "", "zen")).toBe(false); + }); + + it("falls back to the plane-blind row when the plane is unknown or does not carry the id", () => { + // The id below is free on Zen and absent from the Go catalog. A Go request + // for it finds no Go row, so the plane-blind lookup answers — and that row + // is the Zen one, which says free. No seeding: this is the shipped shim. + // The premise, asserted: a regeneration that lists the id on Go would keep + // the three answers below true while the fallback stopped being taken. + expect(findModelSpecOn("go", "big-pickle")).toBeUndefined(); + expect(findModelSpecOn("zen", "big-pickle")?.is_free).toBe(true); + expect( + isCoreToolModel("big-pickle", DEFAULT_FREE_MODEL_MARKER, "zen") + ).toBe(true); + expect(isCoreToolModel("big-pickle", DEFAULT_FREE_MODEL_MARKER, "go")).toBe( + true + ); + expect(isCoreToolModel("big-pickle", DEFAULT_FREE_MODEL_MARKER)).toBe(true); + }); + + it("maybeInjectCoreTools reads the plane from the URL's path", async () => { + await seedDivergentCatalog(); + const body = JSON.stringify({ model: ZEN_FREE_PROBE }); + + const zenHeaders = new Headers(); + const zen = maybeInjectCoreTools(RESPONSES_URL, body, zenHeaders, { + enabled: true, + modelMarker: "none", + }); + expect(parseJsonBody(bodyText(zen))).toEqual({ + model: ZEN_FREE_PROBE, + tools: [DUMMY_READ_TOOL, DUMMY_BASH_TOOL], + }); + expect(zenHeaders.get("content-length")).toBe( + String(Buffer.byteLength(bodyText(zen))) + ); + + const goHeaders = new Headers(); + const go = maybeInjectCoreTools( + `https://opencode.ai/zen/go/v1${RESPONSES_PATH}`, + body, + goHeaders, + { enabled: true, modelMarker: "none" } + ); + expect(go).toBe(body); + expect(goHeaders.get("content-length")).toBeNull(); + }); +}); diff --git a/test/turn-store.test.ts b/test/turn-store.test.ts index e9c6590b..bdd45853 100644 --- a/test/turn-store.test.ts +++ b/test/turn-store.test.ts @@ -20,7 +20,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { describe, expect, it } from "vitest"; -import { type ActiveTurnState, withStore } from "../src/index.ts"; +import { type ActiveTurnState, withStore } from "./support/src.ts"; import { collectUnknown, createMockStoreStream } from "./test-helpers.ts"; /** A fresh store per case: the module has no shared state, and neither should a test. */ diff --git a/test/usage-contract.test.ts b/test/usage-contract.test.ts index 814cbcdb..57117b56 100644 --- a/test/usage-contract.test.ts +++ b/test/usage-contract.test.ts @@ -16,7 +16,7 @@ import { parseAccountRef, parseGoUsage, parseUsageQuery, -} from "../src/index.ts"; +} from "./support/src.ts"; /** One well-formed quota window, with a single field overridden. */ const windowRow = ( diff --git a/test/usage-panel.test.tsx b/test/usage-panel.test.tsx index 11270591..7fb8732d 100644 --- a/test/usage-panel.test.tsx +++ b/test/usage-panel.test.tsx @@ -12,22 +12,21 @@ import assert from "node:assert/strict"; import { MenuSurface } from "@deepseek-ai/dsh-client-ui-primitives"; import { describe, expect, it, vi } from "vitest"; -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"; import { + CONSOLE_URL, + GO_PLAN_URL, + type GoUsage, + type RelativeReset, + STYLES, + type SessionUsageSnapshot, UsagePanel, type UsagePanelProps, UsageTrigger, type UsageTriggerProps, -} from "../src/usage-panel.tsx"; -import { - CONSOLE_URL, - GO_PLAN_URL, - type RelativeReset, + en as copyEn, resetLabel, - STYLES, -} from "../src/usage-ui.ts"; + zh as copyZh, +} from "./support/src.ts"; import { childrenOf, collectText, diff --git a/test/usage-pill.test.tsx b/test/usage-pill.test.tsx index 734a633f..fb9c20fc 100644 --- a/test/usage-pill.test.tsx +++ b/test/usage-pill.test.tsx @@ -11,14 +11,12 @@ vi.mock("react", async (importOriginal) => { }; }); -import type { GoUsage } from "../src/usage-contract.ts"; import { + CIRCUMFERENCE, + type GoUsage, type ModelDirectoryState, type SnapshotStore, UsagePill, -} from "../src/usage-pill.tsx"; -import { - CIRCUMFERENCE, describeUsage, formatRelativeReset, getAffectingWindow, @@ -27,7 +25,7 @@ import { matchesAny, parseFailure, ringGeometry, -} from "../src/usage-ui.ts"; +} from "./support/src.ts"; const createMockUsage = (overrides?: Partial): GoUsage => ({ monthly: { @@ -141,7 +139,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 +148,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 +187,18 @@ describe("usage-pill: helper functions & calculations", () => { "45m" ); expect(formatRelativeReset(isoAt(ahead(45 * 60 * 1000)), "zh").text).toBe( - "45分" + "45分钟" ); } finally { + // Restored either way: on an engine without the constructor, leaving the + // stand-in installed would make every later case run against it. if (had) { Reflect.set(Intl, "DurationFormat", original); + } else { + Reflect.deleteProperty(Intl, "DurationFormat"); } } + expect(Reflect.get(Intl, "DurationFormat")).toBe(original); }); it("localizes the duration units, not just the label", () => { diff --git a/test/usage-service.test.ts b/test/usage-service.test.ts index 46653ba7..d38eccdb 100644 --- a/test/usage-service.test.ts +++ b/test/usage-service.test.ts @@ -22,7 +22,7 @@ import { clearSessionUsageStore, recordCapturedApiKey, recordTurnUsage, -} from "../src/index.ts"; +} from "./support/src.ts"; import { createMockContext, headerOf, @@ -750,6 +750,18 @@ describe("GoUsageService gateway failures", () => { expect(reading.monthly.percent).toBe(0); }); + it("falls back to Zen overflow when Go usage rejects a 401 on the Zen Google route", async () => { + // `opencode-google` is Zen's Google route, not a Go one: the same 401 that + // falls back on plain `opencode` must fall back here, rather than reaching + // the card as a rejected Go key. + stubFetch(() => new Response('{"type":"error"}', { status: 401 })); + const reading = await serviceWithKey("oc_sk_zen-key").read({ + provider: "opencode-google", + }); + expect(reading.zenOverflow).toBe(true); + expect(reading.quotaUnavailable).toBe(true); + }); + it("rejects an oversized body before parsing it", async () => { // The cap exists because the body is read into memory first; rejecting // after `JSON.parse` would already have paid for the megabyte. diff --git a/test/usage.test.ts b/test/usage.test.ts index fee756f2..9b00ac62 100644 --- a/test/usage.test.ts +++ b/test/usage.test.ts @@ -10,22 +10,21 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { GoUsageService, + KEY_SOURCE_POLICIES, clearCapturedApiKeys, clearSessionUsageStore, discoverGoConfig, extractApiKeyFromHeaders, getCapturedApiKey, isPlaceholderApiKey, - KEY_SOURCE_POLICIES, parseGoUsage, parseUsageQuery, recordCapturedApiKey, recordTurnUsage, resolveGoApiKey, - resolveRoutedKey, resolveZenCreditInfo, tierForRequest, -} from "../src/index.ts"; +} from "./support/src.ts"; import { createMockContext } from "./test-helpers.ts"; afterEach(() => { @@ -419,23 +418,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 +554,7 @@ describe("OpenCode Go Usage", () => { "unknown" ); expect(tierForRequest()).toBe("unknown"); - expect(tierForRequest("", "", "")).toBe("unknown"); + expect(tierForRequest("", "")).toBe("unknown"); }); }); @@ -599,28 +622,5 @@ describe("OpenCode Go Usage", () => { ).toBe("sk-captured-key"); } }); - - it("resolveRoutedKey honours the same order", async () => { - process.env.OPENCODE_GO_API_KEY = "sk-env-key"; - captureGoKey(); - const automatic = await resolveRoutedKey( - withoutDeclarations, - "opencode-go", - "auto" - ); - expect(automatic.key).toBe("sk-captured-key"); - const requested = await resolveRoutedKey( - withoutDeclarations, - "opencode-go", - "request" - ); - const declared = await resolveRoutedKey( - withoutDeclarations, - "opencode-go", - "configured" - ); - expect(requested.key).toBe("sk-captured-key"); - expect(declared.key).toBe("sk-env-key"); - }); }); }); diff --git a/test/use-channel.test.ts b/test/use-channel.test.ts new file mode 100644 index 00000000..37fb3539 --- /dev/null +++ b/test/use-channel.test.ts @@ -0,0 +1,235 @@ +/** + * The pure half of the channel switch (`scripts/channel-manifest.ts`): the + * manifest a channel installs, the floor override and the lockfile a + * `--relock` starts from, as functions of text. + * + * @module test/use-channel + */ +import { describe, expect, it } from "vitest"; +import { parseAllDocuments } from "yaml"; + +import { + channelManifest, + pinFor, + withoutHostPackages, + workspaceWithPi, +} from "../scripts/channel-manifest.ts"; +import { CHANNELS } from "./support/host-versions.ts"; + +const MANIFEST = `${JSON.stringify( + { + name: "x", + dependencies: { "@deepseek-ai/schemastery": "3.18.6-alpha.1" }, + devDependencies: { + "@deepseek-ai/cordis": "4.0.6-alpha.1", + "@deepseek-ai/cordis-plugin-loader": "1.0.7-alpha.1", + "@deepseek-ai/dsh-llm-pi-ai": "0.2.2-alpha.2", + "@earendil-works/pi-ai": "^1.1.0", + semver: "^7.8.0", + }, + }, + null, + 2 +)}\n`; + +describe("channel switch · the manifest a channel installs", () => { + it("moves every @deepseek-ai package to the rc record and leaves the rest alone", () => { + const rc = JSON.parse(channelManifest(MANIFEST, CHANNELS.rc)) as { + dependencies: Record; + devDependencies: Record; + name: string; + }; + expect(rc.name).toBe("x"); + expect(rc.dependencies).toEqual({ + "@deepseek-ai/schemastery": CHANNELS.rc.schemastery, + }); + expect(rc.devDependencies).toEqual({ + "@deepseek-ai/cordis": CHANNELS.rc.cordis, + "@deepseek-ai/cordis-plugin-loader": CHANNELS.rc.loader, + "@deepseek-ai/dsh-llm-pi-ai": CHANNELS.rc.dsh, + "@earendil-works/pi-ai": "^1.1.0", + semver: "^7.8.0", + }); + }); + + it("is the identity for the channel the manifest already pins, formatting included", () => { + expect(channelManifest(MANIFEST, CHANNELS.alpha)).toBe(MANIFEST); + }); + + it("refuses a harness package no rule covers instead of guessing its pin", () => { + const stray = MANIFEST.replace( + '"semver"', + '"@deepseek-ai/cosmokit": "1.8.7-alpha.1",\n "semver"' + ); + expect(() => channelManifest(stray, CHANNELS.rc)).toThrow( + /@deepseek-ai\/cosmokit has no channel rule/u + ); + expect(pinFor("@deepseek-ai/cosmokit", CHANNELS.rc)).toBeUndefined(); + }); +}); + +describe("channel switch · the floor override", () => { + const WORKSPACE = + 'catalog:\n vite-plus: 1.4.0\noverrides:\n vite@*: "catalog:"\n'; + + it("holds every pi in the tree to one version with one override line", () => { + expect(workspaceWithPi(WORKSPACE, "1.1.0")).toBe( + 'catalog:\n vite-plus: 1.4.0\noverrides:\n "@earendil-works/pi-ai": "1.1.0"\n vite@*: "catalog:"\n' + ); + }); + + it("refuses a workspace that already overrides pi, or has no single overrides key", () => { + expect(() => + workspaceWithPi(workspaceWithPi(WORKSPACE, "1.1.0"), "1.3.0") + ).toThrow(/already overrides/u); + expect(() => workspaceWithPi("catalog: {}\n", "1.1.0")).toThrow(/found 0/u); + }); +}); + +describe("channel switch · the lockfile a relock starts from", () => { + // pnpm's real shape: its own package-manager record first, then the + // project document. Host keys come quoted (the scope's `@` forces it), one + // snapshot key carries a peer suffix, and a non-host package names a host + // peer inside its own sub-map, which is not a key the strip may touch. + const PNPM_DOCUMENT = `--- +lockfileVersion: '9.0' + +importers: + + .: + packageManagerDependencies: + pnpm: + specifier: 12.16.1 + version: 12.16.1 + +packages: + + '@pnpm/exe@12.16.1': + resolution: {integrity: sha512-pnpm} + +`; + const LOCK = `${PNPM_DOCUMENT}--- +lockfileVersion: '9.0' + +importers: + + .: + dependencies: + '@deepseek-ai/schemastery': + specifier: 3.18.6-alpha.1 + version: 3.18.6-alpha.1 + yaml: + specifier: 2.9.1 + version: 2.9.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: 4.0.6-alpha.1 + version: 4.0.6-alpha.1 + '@earendil-works/pi-ai': + specifier: ^1.1.0 + version: 1.5.0 + optionalDependencies: + '@deepseek-ai/cordis-plugin-include': + specifier: 1.0.11-alpha.1 + version: 1.0.11-alpha.1 + +packages: + + '@deepseek-ai/cordis@4.0.6-alpha.1': + resolution: {integrity: sha512-cordis} + + '@earendil-works/pi-ai@1.5.0': + resolution: {integrity: sha512-pi} + + yaml@2.9.1: + resolution: {integrity: sha512-yaml} + +snapshots: + + '@deepseek-ai/cordis@4.0.6-alpha.1(@deepseek-ai/cordis-plugin-loader@1.0.7-alpha.1)': + dependencies: + '@deepseek-ai/cosmokit': 1.8.7-alpha.1 + + '@earendil-works/pi-ai@1.5.0': + dependencies: + yaml: 2.9.1 + + node-addon-require-builtin@0.1.9: + peerDependencies: + '@deepseek-ai/cordis': 4.0.6-alpha.1 + + yaml@2.9.1: {} +`; + const SECTIONS = [ + ["importers", ".", "dependencies"], + ["importers", ".", "devDependencies"], + ["importers", ".", "optionalDependencies"], + ["packages"], + ["snapshots"], + ] as const; + + const documents = (text: string) => + parseAllDocuments(text).map((doc) => { + expect(doc.errors).toEqual([]); + return doc.toJS() as Record; + }); + + const keysOf = (doc: Record, path: readonly string[]) => { + let node: unknown = doc; + for (const step of path) { + node = (node as Record | undefined)?.[step]; + } + return Object.keys((node as Record | undefined) ?? {}); + }; + + it("removes every host key from the five sections and keeps everything else", () => { + const input = documents(LOCK); + const output = documents(withoutHostPackages(LOCK)); + expect(output).toHaveLength(2); + // The package-manager record is not the project's and is left alone. + expect(output[0]).toEqual(input[0]); + const project = output[1] ?? {}; + // A strip that read nothing would pass the next assertion: the input + // has a host key in every one of the five sections. + for (const section of SECTIONS) { + expect( + keysOf(input[1] ?? {}, section).some((key) => + key.startsWith("@deepseek-ai/") + ), + section.join(".") + ).toBe(true); + expect( + keysOf(project, section).filter((key) => + key.startsWith("@deepseek-ai/") + ), + section.join(".") + ).toEqual([]); + } + expect(keysOf(project, SECTIONS[0])).toEqual(["yaml"]); + expect(keysOf(project, SECTIONS[1])).toEqual(["@earendil-works/pi-ai"]); + expect(keysOf(project, ["packages"])).toEqual([ + "@earendil-works/pi-ai@1.5.0", + "yaml@2.9.1", + ]); + // The non-host entries survive with their sub-maps, a host peer included. + const snapshots = (project.snapshots ?? {}) as Record; + const before = ((input[1] ?? {}).snapshots ?? {}) as Record< + string, + unknown + >; + expect(Object.keys(snapshots)).toEqual([ + "@earendil-works/pi-ai@1.5.0", + "node-addon-require-builtin@0.1.9", + "yaml@2.9.1", + ]); + for (const key of Object.keys(snapshots)) { + expect(snapshots[key], key).toEqual(before[key]); + } + expect(project.lockfileVersion).toBe("9.0"); + }); + + it("is idempotent: a lockfile with no host key keeps its content", () => { + const once = withoutHostPackages(LOCK); + expect(documents(withoutHostPackages(once))).toEqual(documents(once)); + }); +}); diff --git a/test/vendor-version.test.ts b/test/vendor-version.test.ts index 6161b38a..e951dd96 100644 --- a/test/vendor-version.test.ts +++ b/test/vendor-version.test.ts @@ -16,7 +16,9 @@ import { describe, expect, it } from "vitest"; import { bareLiteral, disagreements, + GENERATED_TABLES, pinnedVersion, + scansFile, } from "../scripts/vendor-version.ts"; /** @@ -101,3 +103,38 @@ describe("copies that disagree with the constant", () => { expect(disagreements(PINNED, agreeing, agreeing)).toEqual([]); }); }); + +describe("which files the copy scan reads", () => { + it("reads the files a copy can live in", () => { + for (const path of [ + "src/identity.ts", + "src/settings-page.tsx", + "README.md", + "docs/a.md", + "test/fixtures/x.json", + "scripts/x.yaml", + ".github/workflows/ci.yml", + ]) { + expect(scansFile(path), path).toBe(true); + } + }); + + it("skips the generated tables, which hold vendor data rather than copies", () => { + // The premise: both are TypeScript, so the extension alone would read them. + // Only the exclusion keeps a refresh from failing a gate that only the + // repository is supposed to be able to break. + expect([...GENERATED_TABLES].toSorted()).toEqual([ + "src/catalog-data.ts", + "src/go-limits-data.ts", + ]); + for (const path of GENERATED_TABLES) { + expect(path).toMatch(/\.ts$/u); + expect(scansFile(path), path).toBe(false); + } + }); + + it("skips a file no copy can live in", () => { + expect(scansFile("docs/media/hero.svg")).toBe(false); + expect(scansFile("lib/client.js")).toBe(false); + }); +}); diff --git a/test/wrapper-forwarder.test.ts b/test/wrapper-forwarder.test.ts deleted file mode 100644 index d9a196ef..00000000 --- a/test/wrapper-forwarder.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * The retired alias's forwarder, linked against the REAL built barrel. - * - * The alias is a published package, and the thing it exists for — letting an - * existing install keep resolving — is what it broke: its forwarder re-exported - * a `default` the canonical package does not have, so every load was a link - * error before the plugin ran at all. - * - * **The check is a real Node process, not vite-node.** A bundler turns a - * missing export into `undefined` and stays green, which is how a package that - * cannot be imported ships with a passing suite. So the test writes the - * forwarder next to a copy of the BUILT `lib/index.mjs` and lets Node link it. - * - * @module test/wrapper-forwarder.test - */ - -import { execFileSync } from "node:child_process"; -import { - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { wrapperForwarder } from "../scripts/wrapper-forwarder.ts"; - -/** The built barrel, which is what the wrapper forwards to. */ -const BUILT = join(import.meta.dirname, "..", "lib", "index.mjs"); - -describe("the retired alias's forwarder", () => { - it("links against the built barrel in a real Node process", () => { - // The built barrel is what ships; if it is missing, the bundle gate - // failed and there is nothing to link against. - expect( - existsSync(BUILT), - "lib/index.mjs is missing — run `pnpm run build` first" - ).toBe(true); - - const root = mkdtempSync(join(tmpdir(), "dsh-wrapper-")); - try { - // A package tree the wrapper can resolve: the canonical package under - // its name, with the barrel that was actually built. - const canonical = join(root, "node_modules", "dsh-opencode-patch"); - mkdirSync(join(canonical, "lib"), { recursive: true }); - cpSync(BUILT, join(canonical, "lib", "index.mjs")); - // The barrel's own imports have to resolve from the temp tree, or Node - // fails on the DEPENDENCY and never reaches the export check. - for (const dependency of [ - "@deepseek-ai/schemastery", - "@deepseek-ai/dsh-typert-protocol", - ]) { - const from = join( - import.meta.dirname, - "..", - "node_modules", - dependency - ); - if (!existsSync(from)) { - continue; - } - const to = join(root, "node_modules", dependency); - mkdirSync(join(to, ".."), { recursive: true }); - symlinkSync(from, to, "dir"); - } - writeFileSync( - join(canonical, "package.json"), - JSON.stringify({ - exports: { ".": "./lib/index.mjs" }, - name: "dsh-opencode-patch", - type: "module", - version: "0.0.0-test", - }) - ); - - const wrapper = join(root, "wrapper"); - mkdirSync(join(wrapper, "lib"), { recursive: true }); - writeFileSync( - join(wrapper, "lib", "index.mjs"), - `${wrapperForwarder()}\n` - ); - writeFileSync( - join(wrapper, "package.json"), - JSON.stringify({ - name: "dsh-opencode-patch-wrapper", - type: "module", - }) - ); - - // Node links it. A `default` the barrel does not have is a SyntaxError - // here — and nothing else in the suite would ever see it. - const probe = join(root, "probe.mjs"); - writeFileSync( - probe, - `import * as ns from "${join(wrapper, "lib", "index.mjs")}";\n` + - `console.log(Object.keys(ns).length);\n` - ); - const out = execFileSync(process.execPath, [probe], { - encoding: "utf8", - }); - expect(Number(out.trim())).toBeGreaterThan(0); - } finally { - rmSync(root, { force: true, recursive: true }); - } - }); - - it("names only what the canonical package has", () => { - // The forwarder is a star re-export and nothing else: a named re-export - // can name something the target does not have, and that is a link error. - expect(wrapperForwarder()).toBe('export * from "dsh-opencode-patch";'); - expect(wrapperForwarder()).not.toContain("default"); - }); -}); diff --git a/vite.config.ts b/vite.config.ts index 1ed574b4..5f2fff6e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -148,8 +148,8 @@ export default defineConfig({ " return module.exports;", " };", // The two names this bundle is published as, plus the pre-rename - // component id an old cordis row resolves through. Not the deprecated - // `@viztor/dsh-opencode` wrapper — it ships no client half (AGENTS.md). + // component id an old cordis row resolves through. Not + // `@viztor/dsh-opencode`, which is no longer published. ' window.__ModuleLoader__.load({ id: "dsh-opencode-patch", factory: factory });', ' try { window.__ModuleLoader__.load({ id: "@viztor/dsh-opencode-patch", factory: factory }); } catch (e) {}', ' try { window.__ModuleLoader__.load({ id: "dsh-opencode", factory: factory }); } catch (e) {}', @@ -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,