From e7ad3df121d1a5ee3f842ba4e70d99374802cab1 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 5 Nov 2026 03:35:50 +0800 Subject: [PATCH] fix: show the host's purge refusal and say auto is modern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase **P1a** of `docs/improvement-plan.md`, restacked onto 1.8.1. Most of what shipped broken in 1.4.1 is on `main` now (#133, #135), so this is what is left: - **Purge refusal.** The purge row shows the `message` of a refusal whose `error` is the remote protocol's serialized `RemoteError`. `main` reads only a string `message` or `error`, so that refusal was reported without its reason. - **The endpoint copy (D19).** The schema description, the `config-values` doc, the card's hint and Automatic label, both READMEs and `docs/redirect-planes.md` name the per-protocol inference family for `modern`, with `auto` as its alias. A test asserts that `auto` resolves exactly as `modern` does. - **`isZenProvider("opencode-google")` regression tests.** The predicate, its agreement with `catalogPlaneForRoute`, and the usage service's Zen fallback on that route. The fix itself is on `main`. - **Warning colour.** Both rows use the bare `var(--dsw-alias-state-warn-label)` — no token fallback, because the fallback is what hid the original bug — and `host-tokens.test.ts` checks every token the card files name against the host's primitives vocabulary. - **Catalog refusal tests.** Three cases `main` lacks: the Zen-plane 404 fill, the non-JSON 200 answer, and the `retry-after` passthrough. - **Debug rule (D24).** Naming `debugFile` writes the file; `debug` governs the console only. Dropped because `main` now does it: the wrapper fix (#135), the `isZenProvider` code change, the `enrichModelsResponse` passthrough, the spinner keyframes, the `parsePurgeResult` switch, P1a's `endpointModern` label, and P1a's debug gate. Restacked by rebasing the branch's net change onto 1.8.1 rather than merging `main` into it: the branch's own history is two merges of `main`, and replaying them would have carried the pre-refresh catalog back over #151. --- README.md | 4 +- README.zh-CN.md | 4 +- docs/improvement-plan.md | 24 +++---- docs/redirect-planes.md | 2 +- src/config-values.ts | 12 ++-- src/config.ts | 19 ++--- src/google-bridge.ts | 13 ++-- src/pi-provider.ts | 18 ++--- src/settings-card.tsx | 2 +- src/settings-copy.ts | 8 +-- src/settings-purge.ts | 8 ++- src/settings-store-inspect.tsx | 2 +- test/catalog.test.ts | 31 +++++++- test/config.test.ts | 4 +- test/endpoint-copy.test.ts | 72 +++++++++++++++++++ test/host-tokens.test.ts | 49 +++++++++++++ test/pi-provider.test.ts | 22 ++++-- test/request-shape.test.ts | 38 ++++++++++ test/settings-card-purge.test.tsx | 30 ++++++++ test/settings-purge.test.ts | 27 +++++++ test/settings-store-inspect.test.tsx | 46 ++++++++++++ test/stream-hook.test.ts | 101 ++++++++++++++++++++++++++- test/usage-service.test.ts | 12 ++++ 23 files changed, 484 insertions(+), 64 deletions(-) create mode 100644 test/endpoint-copy.test.ts diff --git a/README.md b/README.md index 8e9435aa..c74079d0 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ Nine controls in four sections — the decisions a user actually makes. Everythi | **Models & Free Tier** | Enrich model metadata from the OpenCode catalog | `on` | Fills catalog context limits, modalities and thinking levels into model info, and lets the gateway `/models` listing carry the full catalog. Does **not** add models to the picker — the picker shows declared models only; use the card's **Check catalog** row to declare more | | **Models & Free Tier** | Inject Core Tools | `on` | Adds the `read` + `bash` schemas free-tier `/responses` bodies require | | **Quota Meter** | Enable Composer Usage Meter | `on` | Mounts the live meter in the composer dock — the Go quota ring, or the Zen session spend | -| **Account & Endpoint** | API Endpoint | `auto` | Which endpoint family requests go to: **Automatic** (follow the catalog) · **Legacy** (`zen/v1`) · **Modern** (`inference/…`) | +| **Account & Endpoint** | API Endpoint | `auto` | Which endpoint family requests go to: **Automatic** (same as Modern) · **Legacy** (`zen/v1`, `zen/go/v1`) · **Modern** (per-protocol `inference/{openai,anthropic,google}/…`, `inference/go/…` on Go) | | **Account & Endpoint** | Credential Source | `auto` | Which key wins when several are known: **Automatic** · **Live request first** · **Declared key first** | | **Account & Endpoint** | Read the OpenCode installation's key | `off` | Lets the plugin read the credential OpenCode itself stored, as the LAST resort — off by default | @@ -259,7 +259,7 @@ Nine controls in four sections — the decisions a user actually makes. Everythi Two questions decide where a request goes, and the **Account & Endpoint** section is where you answer them. -**Which endpoint family.** The gateway answers on two families — the older `/zen/v1` and the newer `inference` family, whose base is per protocol (`…/inference/openai/v1`, `…/inference/anthropic/v1`, `…/inference/google/v1beta`) — and they are entitled **separately**: an account that works on one is not guaranteed to work on the other. `auto` follows the gateway's own catalog, which today names the newer family, so a catalog move carries you along; `legacy` and `modern` pin one, which is what you want when your account only works on the older one, or when you are checking that the newer one does. +**Which endpoint family.** The gateway answers on two families — the older `/zen/v1` (`/zen/go/v1` on Go), one base for every protocol, and the newer per-protocol `inference` family (`/inference/openai/v1`, `/inference/anthropic/v1`, `/inference/google/v1beta`, with `/inference/go/…` on Go) — and they are entitled **separately**: an account that works on one is not guaranteed to work on the other. `modern` sends each protocol to its own inference path; `auto`, the default, is an alias of `modern`; `legacy` pins the older family, which is what you want when your account only works there. **Which credential wins.** `keySource` orders the sources the plugin can see — `auto` prefers a key you declared, then one captured from a live request, then the declared reference; `request` promotes a captured key first; `configured` does the reverse. Below it, `readOpencodeStore` lets the plugin read the credential OpenCode itself stored, as the **last** resort only. Both are `off`/`auto` by default, so nothing changes until you say so. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3ebf846c..bbec6f19 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.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` 与关闭,因此在你说要改之前一切照旧。 diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 4917dc32..b0bca5b4 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -21,7 +21,7 @@ Phases land as **stacked pull requests**. Each one is based on the previous phas | Phase | PR | Title | State | Restack onto 1.8.0 | | :-- | :-- | :-- | :-- | :-- | | P0 | #105 | suite hardening, 10 defect fixes, this plan | in review | **merged `main` 1.8.0**; catalog refreshes and the pricing fix dropped (on `main`) | -| P1a | #106 | `fix:` shipped breakage | in review | pending: merges P0; shrinks hard (title changes) | +| 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 | pending: merges P1a; drops its tool-fallback variant (D25) | | P2 | #108 | `feat:` a smaller public API | in review | pending: merges P1b; re-retires the wrapper (D9) | | P-T1a | #109 | `build:` pin the host | in review | pending: merges P2; redone on the alpha cluster and pi 1.3.0 (D14', D22') | @@ -112,11 +112,11 @@ Phases land as **stacked pull requests**. Each one is based on the previous phas | 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. | `models-catalog.ts:731` | +| ~~`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; P1a reads its `message`. | `settings-purge.ts:56-69` | +| `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`. | `stream-hook.ts:44,200-203,258` | | The partition test misses `debugFile` | `test/settings-page.test.tsx:1223` | | `cordis.patch.yml`: phantom `showUsagePrice`, wrong `freeModelMarker: ''` text, incomplete field lists | `cordis.patch.yml:23,36,61` | @@ -337,15 +337,13 @@ What stays private is pinned by contract tests against the real host packages: - completions models move from `/zen(/go)/v1` to `/inference(/go)/openai/v1`; - `decoratePlaneAdapters`' baseUrl repair never reaches the wire. -1. **P1a `fix:` shipped breakage.** - - The wrapper's broken `export { default }`. - - `isZenProvider("opencode-google")` answers false. - - `enrichModelsResponse` passes a 401/403/5xx through, and still fills the Go plane's 404. - - The warning colour uses `--dsw-alias-state-warn-label`. - - The spinner keyframes are in the settings styles. - - The debug gate follows D24. - - `settings-purge` uses `parsePurgeResult`. - - The endpoint copy (`config.ts`, `config-values.ts`, README) names the per-protocol inference family for `modern`, with `auto` as its alias. +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/`. @@ -452,7 +450,7 @@ How each phase changes now that it sits on `main` 1.8.0 and the alpha host. Each | Phase | Adjustment | | :-- | :-- | | P0 | Shrinks: the three catalog refreshes and the per-plane pricing fix are on `main`. Keeps the suite hardening, the lifecycle, mount and credential fixes, D23's scoping with `--copies`, and this plan. | -| P1a | Shrinks hard: the purge row shows the host's `RemoteError.message`, the endpoint copy says `auto` is `modern`, and `isZenProvider("opencode-google")` gains a regression test. The wrapper fix, refusal passthrough, warning token, spinner and `parsePurgeResult` are on `main` or superseded; the debug gate follows D24. | +| 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. | | P-T1a | Redone on D14' and D22': `main`'s pins, then the pi 1.3.0 override and patch; the 1.1.0 patch is deleted. | diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 97e78689..6d9251de 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -15,7 +15,7 @@ A **plane** is one gateway base URL with its own catalog and its own credential. 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` 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. +**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. diff --git a/src/config-values.ts b/src/config-values.ts index 073084e5..c0093149 100644 --- a/src/config-values.ts +++ b/src/config-values.ts @@ -76,14 +76,10 @@ export const isKeySourcePolicy = (value: unknown): value is KeySourcePolicy => * The gateway answers on two families and the user, not the catalog, is the one * who knows which their account is entitled to: * - * - `auto` follows the catalog — the `api` the vendor's own entry names, which - * today is the `inference` family. - * - `legacy` forces the older pair, which really is one base per plane - * (`…/zen/v1`, `…/zen/go/v1`). - * - `modern` forces the newer `inference` family, whose base is PER PROTOCOL - * (`…/inference/openai/v1`, `…/inference/anthropic/v1`, - * `…/inference/google/v1beta`, and the `go/` variants) — so "inference/v1" is - * not an address the plugin ever sends, and the copy says `inference/…`. + * - `auto` — an alias of `modern` (the default). + * - `legacy` — the older single base per plane, `/zen/v1` and `/zen/go/v1`. + * - `modern` — the per-protocol `inference` family, + * `/inference/{openai,anthropic,google}/…` and `/inference/go/…` on Go. * * Declared here rather than in `config.ts` so the client card can read the same * list without importing schemastery. diff --git a/src/config.ts b/src/config.ts index 551fd3e1..cb300e89 100644 --- a/src/config.ts +++ b/src/config.ts @@ -214,8 +214,8 @@ export interface ResolvedPluginConfig { * Which endpoint family the gateway's requests go to. * * The two families are independently entitled and both are still served, so - * the choice is the user's: `auto` follows the catalog, and `legacy` / - * `modern` pin one. See {@link ENDPOINT_FAMILIES}. + * the choice is the user's: `auto` is an alias of `modern` (the default), and + * `legacy` pins the older single base per plane. See {@link ENDPOINT_FAMILIES}. */ endpoint: EndpointFamily; /** `*` when the core-tool fallback should run for every model. */ @@ -276,8 +276,8 @@ const resolvedKeySource = (raw: unknown): KeySourcePolicy => { * A row's endpoint family, or the default when it names something else. * * Lenient for the same reason {@link resolvedKeySource} is: a typo in a - * hand-edited row degrades to `auto`, which follows the catalog and is the - * answer that cannot be wrong about which family is current. + * hand-edited row degrades to `auto`, the default, so a typo cannot select a + * family the user never named. */ const resolvedEndpoint = (raw: unknown): EndpointFamily => { const value = readString(raw); @@ -563,10 +563,13 @@ export const Config = z.object({ .string() .default(CONFIG_DEFAULTS.endpoint) .description( - "Which endpoint family the gateway's requests go to. auto: follow the " + - "catalog's own address (today the inference family). legacy: force " + - "https://opencode.ai/zen/v1. modern: force " + - "https://opencode.ai/inference/. The two families are separately " + + "Which endpoint family the gateway's requests go to. modern: the " + + "per-protocol inference family, " + + "https://opencode.ai/inference/{openai,anthropic,google}/... on Zen " + + "and https://opencode.ai/inference/go/{openai,anthropic,google}/... " + + "on Go. auto: an alias of modern, and the default. legacy: the older " + + "single base per plane, https://opencode.ai/zen/v1 and " + + "https://opencode.ai/zen/go/v1. The two families are separately " + "entitled and both still answer, so this is a choice rather than a " + "migration; an unrecognised value falls back to auto." ) diff --git a/src/google-bridge.ts b/src/google-bridge.ts index d1d7c07a..8f04702d 100644 --- a/src/google-bridge.ts +++ b/src/google-bridge.ts @@ -411,8 +411,9 @@ const withArgonThinkingLevel = ( * one — a provider is a record pi dispatches through, not a class it * instantiates. `getModels` returns the models of the Zen plane whose SDK is * `@ai-sdk/google`, each carrying the base URL this protocol uses on that - * plane (`…/zen/v1`), so the request lands on - * `…/zen/v1/models/:streamGenerateContent`. + * plane (`…/inference/google/v1beta` under auto or modern, and + * `…/inference/go/google/v1beta` on Go), so the request lands on + * `…/inference/google/v1beta/models/:streamGenerateContent`. * * The streams are pi's raw implementation. The session, client, project and * User-Agent headers are NOT added here: the fetch patch already restores all @@ -420,8 +421,8 @@ const withArgonThinkingLevel = ( * append, so the bridge would only duplicate that work. * * @param specs - the catalogued models the route serves. - * @param family - the endpoint family to send them to; the catalog's own - * address by default. + * @param family - the endpoint family to send them to; auto (an alias of + * modern) by default. * @returns the provider the adapter's collection will hold. */ export const googleProviderFor = ( @@ -566,8 +567,8 @@ export interface GoogleBridgeInputs { */ readonly readOpencodeStore?: boolean; /** - * Which endpoint family the bridge's models are sent to. The catalog's own - * address by default, and the same choice the internal routes use so a + * Which endpoint family the bridge's models are sent to. Auto (an alias of + * modern) by default, and the same choice the internal routes use so a * deployment cannot end up on two families at once. */ readonly endpoint?: EndpointFamily; diff --git a/src/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/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-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) => {
{ // 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 @@ -423,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 diff --git a/test/config.test.ts b/test/config.test.ts index 6b2b1979..e4efd16d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -363,8 +363,8 @@ describe("Config schema", () => { it("accepts each endpoint family and degrades an unknown one to auto", () => { // Same rule as the key-source policy, for the same reason: the family is - // user-facing and hand-editable, and `auto` follows the catalog, so a typo - // degrades to the answer that cannot be wrong about which family is current. + // user-facing and hand-editable, so a typo degrades to `auto`, the default, + // rather than selecting a family the user never named. for (const family of ENDPOINT_FAMILIES) { expect(resolveConfig({ endpoint: family }).endpoint).toBe(family); } diff --git a/test/endpoint-copy.test.ts b/test/endpoint-copy.test.ts new file mode 100644 index 00000000..9388e11d --- /dev/null +++ b/test/endpoint-copy.test.ts @@ -0,0 +1,72 @@ +/** + * What the endpoint knob says it does, on every surface that says it. + * + * The schema description, the card's two locales and both READMEs described + * `modern` as `/inference/v1` — a base the code never sends. `modern` (and + * `auto`, which is the same table today) sends each protocol to its own + * inference path: `/inference/{openai,anthropic,google}/…`, under + * `/inference/go/…` on the Go plane. `legacy` is the older single base per + * plane. Copy is the only place a user learns that, so it is pinned. + * + * @module test/endpoint-copy.test + */ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { Config } from "../src/config.ts"; +import { en, zh } from "../src/settings-copy.ts"; + +/** The first line of a file that contains `marker`, or fail loudly. */ +const lineOf = async (file: string, marker: string): Promise => { + const text = await readFile(new URL(file, import.meta.url), "utf8"); + const line = text.split("\n").find((l) => l.includes(marker)); + if (line === undefined) { + throw new Error(`${file} has no line containing ${marker}`); + } + return line; +}; + +describe("endpoint copy", () => { + it("the endpoint copy names the per-protocol inference family and calls auto an alias of modern", async () => { + const schema = ( + Config as unknown as { + dict: { endpoint: { meta: { description: unknown } } }; + } + ).dict.endpoint.meta.description; + expect(typeof schema).toBe("string"); + const d = String(schema); + expect(d).not.toContain("inference/v1"); + expect(d).toContain("inference/{openai,anthropic,google}"); + expect(d).toContain("inference/go/{openai,anthropic,google}"); + expect(d).toMatch(/auto: an alias of modern/u); + + for (const copy of [en, zh]) { + const card = copy.endpointModern + copy.endpointAuto + copy.endpointHint; + expect(card).not.toContain("inference/v1"); + expect(card).toContain("inference/openai"); + // Legacy is one base per plane, and the Go plane's is not zen/v1. + expect(copy.endpointHint).toContain("zen/go/v1"); + } + expect(en.endpointAuto).toContain("same as Modern"); + expect(zh.endpointAuto).toContain("同新端点"); + + const row = await lineOf("../README.md", "API Endpoint"); + expect(row).not.toContain("inference/v1"); + expect(row).toContain("inference/{openai,anthropic,google}"); + expect(row).toContain("same as Modern"); + const zhRow = await lineOf("../README.zh-CN.md", "API 端点"); + expect(zhRow).not.toContain("inference/v1"); + expect(zhRow).toContain("inference/{openai,anthropic,google}"); + expect(zhRow).toContain("同新端点"); + + for (const [file, marker] of [ + ["../README.md", "**Which endpoint family.**"], + ["../README.zh-CN.md", "**发往哪个端点家族。**"], + ] as const) { + const paragraph = await lineOf(file, marker); + expect(paragraph, file).not.toContain("inference/v1"); + expect(paragraph, file).toContain("/inference/openai/v1"); + } + }); +}); diff --git a/test/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/pi-provider.test.ts b/test/pi-provider.test.ts index ac1c7513..f0bffeae 100644 --- a/test/pi-provider.test.ts +++ b/test/pi-provider.test.ts @@ -13,6 +13,7 @@ import { BASE_FOR_PROTOCOL, baseForProtocol, piModelFor, + planeBaseFor, protocolFor, thinkingLevelMapFor, } from "../src/pi-provider.ts"; @@ -275,9 +276,9 @@ describe("pi-provider · the tables themselves", () => { } }); - it("resolves each endpoint family to its own base, and auto to the catalog", () => { - // `auto` is the catalog's own address and therefore cannot be wrong about - // which family is current; the other two pin one, which is the whole point + it("resolves each endpoint family to its own base, and auto as modern", () => { + // `auto` is an alias of `modern` (the copy says so, test/endpoint-copy); + // `legacy` pins the older single base per plane, which is the whole point // of the setting — the two families are separately entitled. expect(baseForProtocol("opencode", "openai-completions", "auto")).toBe( OPENCODE_PLANE_API.zen @@ -301,6 +302,19 @@ describe("pi-provider · the tables themselves", () => { ); }); + it("resolves auto exactly as modern on every protocol and plane", () => { + for (const plane of ["opencode", "opencode-go"] as const) { + expect(planeBaseFor(plane, "auto")).toBe(planeBaseFor(plane, "modern")); + const protocols = Object.keys(BASE_FOR_PROTOCOL[plane]); + expect(protocols.length).toBe(4); + for (const protocol of protocols) { + expect(baseForProtocol(plane, protocol, "auto"), protocol).toBe( + baseForProtocol(plane, protocol, "modern") + ); + } + } + }); + it("keeps the Anthropic base one level shorter on every family", () => { for (const family of ENDPOINT_FAMILIES) { for (const plane of ["opencode", "opencode-go"] as const) { @@ -333,7 +347,7 @@ describe("pi-provider · the tables themselves", () => { expect( piModelFor("opencode", model, "openai-completions", {}, "modern").baseUrl ).toBe(OPENCODE_PLANE_API.zen); - // No family named means the catalog's own address, as before. + // No family named means the default, auto, which resolves as modern. expect( piModelFor("opencode", model, "openai-completions", {}).baseUrl ).toBe(OPENCODE_PLANE_API.zen); diff --git a/test/request-shape.test.ts b/test/request-shape.test.ts index e2a14ed6..c663d3b9 100644 --- a/test/request-shape.test.ts +++ b/test/request-shape.test.ts @@ -9,17 +9,55 @@ */ import { describe, expect, it } from "vitest"; +import { catalogPlaneForRoute } from "../src/models-catalog.ts"; +import { INTERNAL_ROUTES } from "../src/responses-routes.ts"; import { ANTHROPIC_VERSION, authHeaderFor, gatewayPlaneFor, headerSourceOf, isGoPlaneUrl, + isZenProvider, modelIdOf, requestUrlOf, toolNamesOf, } from "../src/utils/request-shape.ts"; +describe("request-shape · isZenProvider", () => { + it("request-shape · isZenProvider: the Zen Google route is Zen, the Go routes are Go", () => { + // `opencode-google` is the Zen plane's Google route. It starts with + // `opencode-go`, so a predicate that looked for `go` anywhere read it as the + // subscription plan; the plane is `opencode-go` or one of its own + // `opencode-go-*` routes, and nothing else. + expect(isZenProvider("opencode-google")).toBe(true); + expect(isZenProvider("opencode-go-google")).toBe(false); + expect(isZenProvider("opencode-go-responses")).toBe(false); + expect(isZenProvider("opencode-go-anthropic")).toBe(false); + expect(isZenProvider("opencode-go")).toBe(false); + expect(isZenProvider("OpenCode-Go")).toBe(false); + expect(isZenProvider("opencode")).toBe(true); + expect(isZenProvider("OPENCODE")).toBe(true); + expect(isZenProvider("opencode-anthropic")).toBe(true); + expect(isZenProvider("opencode-responses")).toBe(true); + expect(isZenProvider("deepseek")).toBe(false); + expect(isZenProvider()).toBe(false); + }); + + it("agrees with catalogPlaneForRoute on every route the plugin serves", () => { + // The defect was these two predicates disagreeing; a route added to or + // renamed in responses-routes.ts must not let them drift apart again. The + // set is bounded on purpose: outside OpenCode routes the two differ by + // design (isZenProvider wants an `opencode` name; the plane defaults). + const routes = ["opencode", "opencode-go", ...INTERNAL_ROUTES]; + expect(routes.length).toBeGreaterThan(2); + for (const route of routes) { + expect(isZenProvider(route), route).toBe( + catalogPlaneForRoute(route) === "zen" + ); + } + }); +}); + describe("request-shape · the plane of a path", () => { it("reads the Go plane out of both endpoint families", () => { // The Go plane moved to the `inference` family, and three places ask this diff --git a/test/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-purge.test.ts b/test/settings-purge.test.ts index 0b34a917..0be6a2a5 100644 --- a/test/settings-purge.test.ts +++ b/test/settings-purge.test.ts @@ -50,6 +50,33 @@ describe("purgeDeprecatedModels", () => { }); }); +describe("purgeDeprecatedModels: the remote protocol's refusal", () => { + it("carries the host's refusal envelope in its own words", async () => { + // The remote protocol answers a failure as `{ ok: false, error }`, with + // `error` a serialized RemoteError: the reason is its `message`. + await expect( + purgeDeprecatedModels( + scopeWith(async () => ({ + error: new Error("no config editor on this host"), + ok: false, + })) + ) + ).resolves.toEqual({ + kind: "failed", + message: "no config editor on this host", + }); + }); + + it("names a refusal that carries no message", async () => { + await expect( + purgeDeprecatedModels(scopeWith(async () => ({ error: {}, ok: false }))) + ).resolves.toEqual({ + kind: "failed", + message: "the host refused the purge", + }); + }); +}); + describe("purgeDeprecatedModels: the paths a scope can take", () => { it("reads the namespace through ctx.get, the path that works at runtime", () => { // The `scope.remote.opencodeModelPurge` property read in the cases above is diff --git a/test/settings-store-inspect.test.tsx b/test/settings-store-inspect.test.tsx index db97239d..fc959318 100644 --- a/test/settings-store-inspect.test.tsx +++ b/test/settings-store-inspect.test.tsx @@ -269,4 +269,50 @@ describe("settings-store-inspect: the row", () => { host.remove(); } }); + + it("renders a problem note in the host's warning text colour", async () => { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + act(() => { + root.render( + createElement(StoreInspectRow, { + onInspect: () => + Promise.resolve({ kind: "unavailable" }), + t, + }) + ); + }); + const button = [...host.querySelectorAll("button")].find((b) => + (b.textContent ?? "").includes(t("storeInspect")) + ); + act(() => { + button?.click(); + }); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + + // The innermost div whose whole text is the note: with no rows, the + // report's wrapper carries the same text. + const note = [...host.querySelectorAll("div")].find( + (d) => + d.textContent === t("storeInspectUnavailable") && + d.querySelector("div") === null + ); + expect(note, "the note must be rendered").toBeDefined(); + expect(note?.style.color).toBe("var(--dsw-alias-state-warn-label)"); + } finally { + act(() => { + root.unmount(); + }); + host.remove(); + } + }); }); diff --git a/test/stream-hook.test.ts b/test/stream-hook.test.ts index 9134b22b..ddd6d331 100644 --- a/test/stream-hook.test.ts +++ b/test/stream-hook.test.ts @@ -16,9 +16,18 @@ 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 { recordDebug } from "../src/debug.ts"; import { projectNameFrom } from "../src/stream-hook.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[] = []; @@ -189,6 +198,96 @@ describe("stream-hook · the pass-through guards", () => { }); }); +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 } = await import("../src/stream-hook.ts"); + const { AsyncLocalStorage } = await import("node:async_hooks"); + const { resolveConfig } = await import("../src/config.ts"); + 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 diff --git a/test/usage-service.test.ts b/test/usage-service.test.ts index 46653ba7..3290f207 100644 --- a/test/usage-service.test.ts +++ b/test/usage-service.test.ts @@ -750,6 +750,18 @@ describe("GoUsageService gateway failures", () => { expect(reading.monthly.percent).toBe(0); }); + it("falls back to Zen overflow when Go usage rejects a 401 on the Zen Google route", async () => { + // `opencode-google` is Zen's Google route, not a Go one: the same 401 that + // falls back on plain `opencode` must fall back here, rather than reaching + // the card as a rejected Go key. + stubFetch(() => new Response('{"type":"error"}', { status: 401 })); + const reading = await serviceWithKey("oc_sk_zen-key").read({ + provider: "opencode-google", + }); + expect(reading.zenOverflow).toBe(true); + expect(reading.quotaUnavailable).toBe(true); + }); + it("rejects an oversized body before parsing it", async () => { // The cap exists because the body is read into memory first; rejecting // after `JSON.parse` would already have paid for the megabyte.