From a03423e40616dc604676c7fa4085269601c3aa7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:24:31 +0000 Subject: [PATCH 01/17] test: make the suite engine-independent and guard the client boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop the run on a Node below engines.node, before collecting a file. Below the floor, failures came from the engine (node:sqlite, Intl.DurationFormat, ICU strings) and looked like code regressions. .node-version names the Node CI uses. - Add a CI job that runs the unit and mock suites on Node 24, the floor engines.node promises but no job ran. - The zh fallback duration unit is now 分钟, the platform's own narrow output, so the meter row reads the same with or without Intl.DurationFormat. The DurationFormat tests restore the engine as they found it, rather than leaving a stand-in installed or asserting the engine ships the constructor. - Move the node:sqlite store fixture out of the helpers that the jsdom tests share, into test/support/opencode-store-fixture.ts. - Add an architecture test that walks the client bundle's runtime import graph from settings-page.tsx. It forbids node built-ins, the cordis runtime, pi, and process.env. Checked by planting a violation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- .github/workflows/ci.yml | 20 ++++ .node-version | 1 + src/usage-ui.ts | 4 +- test/architecture.test.ts | 103 +++++++++++++++++++ test/fetch-patch.test.ts | 2 +- test/go-discovery.test.ts | 4 +- test/node-engine.test.ts | 62 ++++++++++++ test/support/node-engine.ts | 75 ++++++++++++++ test/support/opencode-store-fixture.ts | 131 +++++++++++++++++++++++++ test/test-helpers.ts | 123 ----------------------- test/usage-pill.test.tsx | 17 +++- vite.config.ts | 3 + 12 files changed, 413 insertions(+), 132 deletions(-) create mode 100644 .node-version create mode 100644 test/node-engine.test.ts create mode 100644 test/support/node-engine.ts create mode 100644 test/support/opencode-store-fixture.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5f0ba30..e0914c5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,26 @@ jobs: path: coverage retention-days: 7 + # `engines.node` promises >=24 and every other job runs 26, so the floor was a + # claim nothing exercised. The suites that depend on the engine (node:sqlite, + # Intl.DurationFormat and its ICU strings) are exactly the ones that differ + # between majors, so the floor gets its own offline run of the unit and mock + # suites. Raise this together with `engines.node`. + engine-floor: + name: test · node 24 (engines floor) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run test + - 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 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/src/usage-ui.ts b/src/usage-ui.ts index 727da1ca..b51358fe 100644 --- a/src/usage-ui.ts +++ b/src/usage-ui.ts @@ -687,7 +687,9 @@ const durationText = ( } const zh = locale?.startsWith("zh") === true; const units = zh - ? { d: "天", h: "小时", m: "分" } + ? // `分钟`, not `分`: the platform's narrow zh output, so a row reads the + // same on an engine without the constructor as on one with it. + { d: "天", h: "小时", m: "分钟" } : { d: "d", h: "h", m: "m" }; const out: string[] = []; if (parts.days !== undefined && parts.days > 0) { diff --git a/test/architecture.test.ts b/test/architecture.test.ts index 53c1f050..802dc2db 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -254,3 +254,106 @@ describe("architecture · utils/ holds only pure functions", () => { expect(violations).toEqual([]); }); }); + +describe("architecture · the client bundle reaches nothing host-only", () => { + const SRC = join(import.meta.dirname, "..", "src"); + /** The client bundle's entry, as `vite.config.ts` names it. */ + const ENTRY = "settings-page.tsx"; + + /** + * Runtime imports of one file. A type-only import is erased by the compiler, + * so it is no edge in the bundle and is skipped; a mixed one is a real edge. + */ + const runtimeImportsOf = (source: string): string[] => { + const specifiers: string[] = []; + for (const match of source.matchAll( + /\b(?:import|export)\s+(type\s+)?(?:[^;"']*?\sfrom\s+)?"([^"]+)"/gu + )) { + if (match[1] === undefined && match[2] !== undefined) { + specifiers.push(match[2]); + } + } + return specifiers; + }; + + /** Every `src/` file the entry reaches at runtime, entry included. */ + const clientGraph = (): Map => { + const seen = new Map(); + const visit = (file: string): void => { + if (seen.has(file)) { + return; + } + const source = stripComments(readFileSync(join(SRC, file), "utf8")); + seen.set(file, source); + for (const specifier of runtimeImportsOf(source)) { + if (specifier.startsWith("./") || specifier.startsWith("../")) { + visit(join(file, "..", specifier).replaceAll("\\", "/")); + } + } + }; + visit(ENTRY); + return seen; + }; + + /** What a client module may not import, each with the reason it is host-only. */ + const HOST_ONLY: readonly { + readonly matches: (specifier: string) => boolean; + readonly because: string; + }[] = [ + { + because: "a node built-in does not exist in the browser", + matches: (s) => s.startsWith("node:"), + }, + { + because: + "the cordis runtime is the host's, and the client gets its own context", + matches: (s) => s === "@deepseek-ai/cordis", + }, + { + because: + "pi is loaded from the host's installation, never shipped to a page", + matches: (s) => s.startsWith("@earendil-works/pi-ai"), + }, + ]; + + it("walks a graph that actually contains the client modules", () => { + // A walker that resolved nothing would pass every rule below vacuously. + const graph = clientGraph(); + expect(graph.has(ENTRY)).toBe(true); + expect(graph.has("usage-pill.tsx")).toBe(true); + expect(graph.has("config-values.ts")).toBe(true); + // And it must not wander into the host half through a type-only import. + expect(graph.has("lifecycle.ts")).toBe(false); + }); + + it("imports no node built-in, no cordis runtime and no pi", () => { + const violations: string[] = []; + for (const [file, source] of clientGraph()) { + for (const specifier of runtimeImportsOf(source)) { + for (const rule of HOST_ONLY) { + if (rule.matches(specifier)) { + violations.push(`${file} imports "${specifier}" — ${rule.because}`); + } + } + } + } + expect(violations).toEqual([]); + }); + + it("never reads process.env, which the browser does not have", () => { + // The bundle test runs the built file in a VM, so a read inside a function + // that never runs there passes it. Reading the source closes that gap. + const violations = [...clientGraph()] + .filter(([, source]) => /\bprocess\.env\b/u.test(source)) + .map(([file]) => file); + expect(violations).toEqual([]); + }); + + it("flags a planted violation, so a pass means something", () => { + // The rule is run over a source string with a known answer. + const planted = + 'import { readFileSync } from "node:fs";\nimport type { X } from "node:os";'; + expect(runtimeImportsOf(planted)).toEqual(["node:fs"]); + expect(HOST_ONLY.some((rule) => rule.matches("node:fs"))).toBe(true); + }); +}); diff --git a/test/fetch-patch.test.ts b/test/fetch-patch.test.ts index 6bf4e61e..8be3e5d8 100644 --- a/test/fetch-patch.test.ts +++ b/test/fetch-patch.test.ts @@ -24,9 +24,9 @@ import { resolveConfig, type ActiveTurnState, } from "../src/index.ts"; +import { createOpencodeStore } from "./support/opencode-store-fixture.ts"; import { createCaptureFetch, - createOpencodeStore, headerOf, parseJsonBody, toolNamesOf, diff --git a/test/go-discovery.test.ts b/test/go-discovery.test.ts index 20b33886..dd6087b1 100644 --- a/test/go-discovery.test.ts +++ b/test/go-discovery.test.ts @@ -52,11 +52,11 @@ import { recordCapturedApiKey, } from "../src/key-capture.ts"; import { - createMockContext, createOpencodeStore, type FixtureCredential, type FixtureStore, -} from "./test-helpers.ts"; +} from "./support/opencode-store-fixture.ts"; +import { createMockContext } from "./test-helpers.ts"; /** The Zen plane, which discovery rewrites onto the Go plane. */ const ZEN_BASE = "https://opencode.ai/zen/v1"; diff --git a/test/node-engine.test.ts b/test/node-engine.test.ts new file mode 100644 index 00000000..33e749f8 --- /dev/null +++ b/test/node-engine.test.ts @@ -0,0 +1,62 @@ +/** + * 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}`); + }); +}); 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/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/test-helpers.ts b/test/test-helpers.ts index ca421970..79893bff 100644 --- a/test/test-helpers.ts +++ b/test/test-helpers.ts @@ -6,11 +6,7 @@ import assert from "node:assert/strict"; import { AsyncLocalStorage } from "node:async_hooks"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import os from "node:os"; -import nodePath from "node:path"; -import { DatabaseSync } from "node:sqlite"; import { setTimeout as sleep } from "node:timers/promises"; import type { ActiveTurnState } from "../src/index.ts"; @@ -178,125 +174,6 @@ export const createMockContext = () => reflect: { provide: () => disposeNoop }, }) as never; -/* ------------------------------------------------- OpenCode credential store */ - -/** One row of the installation's `credential` table, as a fixture. */ -export interface FixtureCredential { - /** The integration the credential belongs to: `opencode`, `opencode-go`, … */ - integrationId: string; - /** The credential's own name; defaults to the integration id. */ - label?: string; - /** The secret. A fixture shaped like one, never a real credential. */ - value: string; - /** `1` for the credential in use, `0` for one the user already replaced. */ - active?: number; -} - -/** One row of the installation's `account` table, plus the state that owns it. */ -export interface FixtureAccount { - /** The access token. A fixture shaped like one, never a real credential. */ - accessToken: string; - /** The account's email; not a secret, and not required. */ - email?: string; - /** The org the account is active in, written to `account_state`. */ - orgId?: string; - /** The refresh token, when the fixture should carry one. */ - refreshToken?: string; - /** - * The stored expiry, in epoch milliseconds. - * - * Deliberately a knob: the plugin uses a stored account whatever this says, - * and a fixture has to be able to put it in the past to prove that. - */ - tokenExpiry?: number; -} - -/** A credential store the test owns, and the way to point the reader at it. */ -export interface FixtureStore { - /** Set this as `$XDG_DATA_HOME`: the reader resolves its path from it. */ - root: string; - /** Remove the whole root. Call from `afterEach`. */ - cleanup: () => void; -} - -/** - * Build an OpenCode credential database in a temporary XDG data root. - * - * The reader resolves its own path from the environment, so a fixture is not a - * mock: this writes the real schema to a real SQLite file and hands back the - * root to export, which is the only thing that makes the store this test's - * rather than whatever the machine happens to have installed. - * - * @param credentials - the rows to insert, in order. - * @param account - the stored login, when the fixture should have one. - * @returns the data root and its cleanup. - */ -export const createOpencodeStore = ( - credentials: readonly FixtureCredential[], - account?: FixtureAccount -): FixtureStore => { - const root = mkdtempSync( - nodePath.join(os.tmpdir(), "opencode-store-fixture-") - ); - const directory = nodePath.join(root, "opencode"); - mkdirSync(directory, { recursive: true }); - const database = new DatabaseSync(nodePath.join(directory, "opencode.db")); - database.exec(` - CREATE TABLE credential ( - id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, - connector_id TEXT, method_id TEXT, active INTEGER, - time_created INTEGER, time_updated INTEGER - ); - CREATE TABLE account ( - id TEXT PRIMARY KEY, email TEXT, url TEXT, access_token TEXT, - refresh_token TEXT, token_expiry INTEGER, - time_created INTEGER, time_updated INTEGER - ); - CREATE TABLE account_state ( - id INTEGER PRIMARY KEY, active_account_id TEXT, active_org_id TEXT - ); - `); - const insert = database.prepare( - "INSERT INTO credential (id, integration_id, label, value, active) VALUES (?, ?, ?, ?, ?)" - ); - for (const [index, credential] of credentials.entries()) { - insert.run( - `fixture-${index}`, - credential.integrationId, - credential.label ?? credential.integrationId, - credential.value, - credential.active ?? 1 - ); - } - if (account !== undefined) { - database - .prepare( - "INSERT INTO account (id, email, url, access_token, refresh_token, token_expiry) VALUES (?, ?, ?, ?, ?, ?)" - ) - .run( - "fixture-account", - account.email ?? "you@example.com", - "https://opencode.ai/console", - account.accessToken, - account.refreshToken ?? "", - account.tokenExpiry ?? null - ); - // The org lives in its own table, and only the active row is read. - database - .prepare( - "INSERT INTO account_state (id, active_account_id, active_org_id) VALUES (?, ?, ?)" - ) - .run(1, "fixture-account", account.orgId ?? null); - } - database.close(); - return { - cleanup: () => { - rmSync(root, { force: true, recursive: true }); - }, - root, - }; -}; - /* ------------------------------------------------ React element tree helpers */ /** diff --git a/test/usage-pill.test.tsx b/test/usage-pill.test.tsx index 734a633f..e643b3de 100644 --- a/test/usage-pill.test.tsx +++ b/test/usage-pill.test.tsx @@ -141,7 +141,8 @@ describe("usage-pill: helper functions & calculations", () => { formatRelativeReset(isoAt(ms), locale).text; expect(at(ahead((2 * 86400 + 4 * 3600) * 1000), "en")).toBe("2d 4h"); expect(at(ahead((2 * 86400 + 4 * 3600) * 1000), "zh")).toBe("2天4小时"); - expect(at(30 * 60 * 1000 + 500, "zh")).toBe("30分"); + expect(at(30 * 60 * 1000 + 500, "zh")).toBe("30分钟"); + expect(at(ahead((3 * 3600 + 15 * 60) * 1000), "zh")).toBe("3小时15分钟"); expect(at(ahead(2 * 86400 * 1000), "en")).toBe("2d"); expect(at(ahead(45 * 60 * 1000), "en")).toBe("45m"); } finally { @@ -149,9 +150,10 @@ describe("usage-pill: helper functions & calculations", () => { Reflect.set(Intl, "DurationFormat", original); } } - // Read through `Reflect`: this project's `lib` is ES2024, which predates - // the constructor, so `Intl.DurationFormat` is not a typed property. - expect(typeof Reflect.get(Intl, "DurationFormat")).toBe("function"); + // The engine is left as it was found: restored when it shipped the + // constructor, still absent when it did not. Asserting `"function"` here + // instead would test the engine, not the cleanup. + expect(Reflect.get(Intl, "DurationFormat")).toBe(original); }); it("renders an absolute reset in Chinese with 点/分, not HH:mm", () => { @@ -187,13 +189,18 @@ describe("usage-pill: helper functions & calculations", () => { "45m" ); expect(formatRelativeReset(isoAt(ahead(45 * 60 * 1000)), "zh").text).toBe( - "45分" + "45分钟" ); } finally { + // Restored either way: on an engine without the constructor, leaving the + // stand-in installed would make every later case run against it. if (had) { Reflect.set(Intl, "DurationFormat", original); + } else { + Reflect.deleteProperty(Intl, "DurationFormat"); } } + expect(Reflect.get(Intl, "DurationFormat")).toBe(original); }); it("localizes the duration units, not just the label", () => { diff --git a/vite.config.ts b/vite.config.ts index 1ed574b4..6c242696 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -170,6 +170,9 @@ 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"], include: ["test/**/*.test.ts", "test/**/*.test.tsx"], /** * Coverage over `src/` only. From 6ac740f9048880619f23e2eb00a4b260e6d69dde Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:28:20 +0000 Subject: [PATCH 02/17] fix(lifecycle): contain the internal-route mount and follow reloads in discovery - A rejected internal-route mount ran from a `void` IIFE with no catch, so a Google bridge failure became an unhandled rejection, which Node's default mode answers by exiting the process. mountInternalRoutes now owns the mount and its withdrawal, and reports a rejection. - Three early exits in registerResponsesProvider (internalRoutes off, every route declared, no llm-pi-ai) answered `undefined` after the Gemini bridges had already registered. That left them with no disposer, and the next reload failed DUPLICATE_ADAPTER. They now hand back the bridges' disposer. - A mount that failed to start, or that captured nothing, kept its fiber and every route it had registered. Both paths now release the routes and dispose the fiber. - Model discovery was registered on every activation, and the host's duplicate refusal was swallowed, so the first activation's settings answered for the life of the process. It is now registered once per root and answers from the latest activation's settings. Each namespace registers on its own, so one refusal no longer skips the rest. - Move three doc blocks back above the functions they describe. Each fix has a regression test. The two mount tests fail without the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- src/lifecycle.ts | 229 ++++++++++++++++++++------------ src/responses-provider.ts | 41 +++--- test/lifecycle.test.ts | 125 +++++++++++++++++ test/responses-provider.test.ts | 68 ++++++++++ 4 files changed, 360 insertions(+), 103 deletions(-) diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 5579a0a8..5e7214ff 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -95,12 +95,6 @@ const logRenameNotice = (ctx: CordisContext): void => { } }; -/** - * Register the Go quota service and its Typert remote face. - * - * Runs before the fetch patch so a composition without `globalThis.fetch` - * still gets a working meter. - */ /** * Publish a service through `ctx.provide`, when `ctx.plugin` is not available. * @@ -149,6 +143,12 @@ const mountByProvide = ( */ const usageMountedOn = new WeakSet(); +/** + * Register the Go quota service and its Typert remote face. + * + * Runs before the fetch patch so a composition without `globalThis.fetch` + * still gets a working meter. + */ const installUsageService = ( ctx: CordisContext, config: ResolvedPluginConfig @@ -274,14 +274,6 @@ const verifyUsageService = (ctx: CordisContext): void => { ); }; -/** - * Wrap `globalThis.fetch` so claimed OpenCode traffic carries session affinity - * and the gateway headers, restoring both on dispose. - * - * @returns `false` when there is no fetch to wrap. The caller then stops: the - * stream hook and discovery must not attach, or turns would run without the - * session affinity this plugin exists to provide. - */ /** * Roots that already carry the purge service, for the same reason the usage * service keeps its own set: `apply` runs once per live reload, and a service @@ -336,6 +328,14 @@ const installStoreService = (ctx: CordisContext): void => { } }; +/** + * Wrap `globalThis.fetch` so claimed OpenCode traffic carries session affinity + * and the gateway headers, restoring both on dispose. + * + * @returns `false` when there is no fetch to wrap. The caller then stops: the + * stream hook and discovery must not attach, or turns would run without the + * session affinity this plugin exists to provide. + */ const installFetchPatch = ( ctx: CordisContext, config: ResolvedPluginConfig, @@ -401,31 +401,7 @@ const installFetchPatch = ( // Own the Responses route from here, so the user configures nothing: they // keep the `opencode` provider and key they already have. Best-effort — a // route declared in the profile still works if this cannot register. - // The registration is async (it reads `llm-pi-ai` off the loader), so the - // disposer cannot live in a local the effect body fills in later: an effect - // that re-runs before the mount settles would find `undefined` here, release - // nothing, and leave the previous mount's routes registered — and the mount - // that then arrives would be an orphan owned by a disposed effect. So the - // effect owns the WITHDRAWAL, not the mount: a mount that completes after - // disposal is released on the spot, and one that completes before it is - // released by the disposer below. - let stopResponsesProvider: (() => void) | undefined; - let released = false; - const releaseResponsesProvider = (): void => { - released = true; - stopResponsesProvider?.(); - stopResponsesProvider = undefined; - }; - void (async () => { - // The resolved config rides in: the mount reads the endpoint family and - // the installation-store switch from it. - const stop = await registerResponsesProvider(ctx, config); - if (released) { - stop?.(); - return; - } - stopResponsesProvider = stop; - })(); + const releaseResponsesProvider = mountInternalRoutes(ctx, config); return () => { releaseResponsesProvider(); stopCatalogHiding?.(); @@ -450,6 +426,27 @@ const installStreamHook = ( ctx.on?.("llm/stream", createStreamHook(ctx, config, als), { prepend: true }); }; +/** + * The settings the LATEST activation on a root resolved, which is what every + * discovery callback answers from. + * + * Keyed on the root for the same reason the services are: `apply` runs once per + * live reload, and the host refuses a second registration under a namespace that + * is still held. The registrations therefore belong to the process, and only the + * settings they read move with each activation. + */ +const discoveryConfigOn = new WeakMap(); + +/** The namespaces this plugin publishes, and the catalog each one answers with. */ +const DISCOVERY_NAMESPACES: readonly { + readonly ns: string; + readonly catalog: () => readonly CatalogModelSpec[]; +}[] = [ + { catalog: () => [...getLiveGoCatalog(), ...getLiveZenCatalog()], ns: name }, + { catalog: getLiveGoCatalog, ns: "opencode-go" }, + { catalog: getLiveZenCatalog, ns: "opencode" }, +]; + /** * Publish the canonical Go/Zen catalog to DSH's model discovery. * @@ -457,73 +454,112 @@ const installStreamHook = ( * gateway listing enrichment, so both sit behind the one `enrichModels` * toggle: turning it off leaves DSH's own catalog and the raw gateway * listings untouched. + * + * Registered once per root and never withdrawn, like the usage service. The + * earlier shape registered on every activation and swallowed the host's + * duplicate refusal, so the FIRST activation's settings answered forever: a + * reload that changed `modelOverrides`, `maxContextWindow` or `enrichModels` + * changed nothing the picker saw. Each namespace is registered on its own, so + * one refusal no longer skips the others. */ const installModelDiscovery = ( ctx: CordisContext, config: ResolvedPluginConfig ): void => { - if (!config.enrichModels) { + const root: CordisContext = ctx.root ?? ctx; + const registered = discoveryConfigOn.has(root); + if (!registered && !config.enrichModels) { return; } - try { - const { llm } = ctx; - if (typeof llm?.registerModelDiscovery !== "function") { - return; + discoveryConfigOn.set(root, config); + if (registered) { + return; + } + const { llm } = ctx; + if (typeof llm?.registerModelDiscovery !== "function") { + return; + } + const current = (): ResolvedPluginConfig => + discoveryConfigOn.get(root) ?? config; + for (const { ns, catalog } of DISCOVERY_NAMESPACES) { + try { + llm.registerModelDiscovery(ns, () => { + const settings = current(); + return Promise.resolve( + settings.enrichModels + ? toDiscovered( + catalog(), + settings.maxContextWindow, + settings.modelOverrides + ) + : [] + ); + }); + } catch (error) { + // Non-fatal, and said: a picker missing a plane is otherwise a silence. + ctx.logger?.warn?.( + `[dsh-opencode-patch] model discovery for ${ns} did not register`, + error + ); } - llm.registerModelDiscovery(name, () => - Promise.resolve( - toDiscovered( - [...getLiveGoCatalog(), ...getLiveZenCatalog()], - config.maxContextWindow, - config.modelOverrides - ) - ) - ); - llm.registerModelDiscovery("opencode-go", () => - Promise.resolve( - toDiscovered( - getLiveGoCatalog(), - config.maxContextWindow, - config.modelOverrides - ) - ) - ); - llm.registerModelDiscovery("opencode", () => - Promise.resolve( - toDiscovered( - getLiveZenCatalog(), - config.maxContextWindow, - config.modelOverrides - ) - ) - ); - } catch { - // Model discovery registration is non-fatal } }; /** - * Plugin entry: register the quota service, patch `fetch` for OpenCode - * traffic, and capture turn state for configured providers. + * Mount the internal protocol routes for one activation, and own their withdrawal. * - * Order matters: - * 1. the rename notice, so it is the first thing a legacy row logs; - * 2. the quota service, before the fetch patch, so a composition without - * `globalThis.fetch` still gets a working meter; - * 3. the fetch patch, which bails out (warning) when there is nothing to - * wrap — `on` must not be attached in that case, or turns would run - * without session affinity; - * 4. the stream hook and model discovery, which only make sense once the - * request path is patched. + * The registration is async (it reads `llm-pi-ai` off the loader), so the + * disposer cannot live in a local the effect body fills in later: an effect + * that re-runs before the mount settles would find `undefined`, release nothing, + * and leave the previous mount's routes registered. So this owns the + * WITHDRAWAL, not the mount: a mount that completes after release is withdrawn + * on the spot, and one that completes before it is withdrawn by the release. * - * @param ctx - the Cordis host context. - * @param rawConfig - the row config, if any. + * A REJECTED mount is reported and contained. It used to escape as an unhandled + * rejection from a `void` IIFE — which Node's default mode turns into a process + * exit — whenever the Google bridge failed to register. + * + * @param ctx - the host context. + * @param config - the resolved settings the mount reads. + * @param register - the mount itself; a parameter so its failure is testable. + * @returns the release, safe to call at any point and more than once. */ +export const mountInternalRoutes = ( + ctx: CordisContext, + config: ResolvedPluginConfig, + register: typeof registerResponsesProvider = registerResponsesProvider +): (() => void) => { + let stop: (() => void) | undefined; + let released = false; + void (async () => { + let next: (() => void) | undefined; + try { + next = await register(ctx, config); + } catch (error) { + ctx.logger?.warn?.( + "[dsh-opencode-patch] the internal routes did not mount; routes declared in the profile are unaffected", + error + ); + return; + } + if (released) { + next?.(); + return; + } + stop = next; + })(); + return () => { + released = true; + stop?.(); + stop = undefined; + }; +}; + /** * Whether this plane holds a credential the gateway would accept. * - * Deliberately synchronous and deliberately generous: a declared env key, a key - * captured from a live request, or an org written by a completed login. The + * Deliberately synchronous and deliberately generous: a declared env key or a + * key captured from a live request. The * SQLite store is not consulted because it is opt-in and off by default, and * calling a deployment "keyless" when it does hold a credential would hide models * it can actually use. @@ -546,6 +582,23 @@ const hasPlaneCredential = (plane: string): boolean => { return getCapturedApiKey(plane) !== undefined; }; +/** + * Plugin entry: register the quota service, patch `fetch` for OpenCode + * traffic, and capture turn state for configured providers. + * + * Order matters: + * 1. the rename notice, so it is the first thing a legacy row logs; + * 2. the quota service, before the fetch patch, so a composition without + * `globalThis.fetch` still gets a working meter; + * 3. the fetch patch, which bails out (warning) when there is nothing to + * wrap — `on` must not be attached in that case, or turns would run + * without session affinity; + * 4. the stream hook and model discovery, which only make sense once the + * request path is patched. + * + * @param ctx - the Cordis host context. + * @param rawConfig - the row config, if any. + */ export const apply = ( ctx: CordisContext, rawConfig: PluginConfig = {} diff --git a/src/responses-provider.ts b/src/responses-provider.ts index 643ed0b5..35119a82 100644 --- a/src/responses-provider.ts +++ b/src/responses-provider.ts @@ -1068,7 +1068,7 @@ export const registerResponsesProvider = async ( "[dsh-opencode-patch] internal routes are off (internalRoutes: false); a declared provider must serve each protocol" ); reportGaps(); - return undefined; + return bridgeDispose; } const wanted = Object.entries(ROUTE_FOR_PLANE_PROTOCOL).flatMap( ([plane, entry]) => @@ -1102,7 +1102,7 @@ export const registerResponsesProvider = async ( "[dsh-opencode-patch] every internal route is already declared; leaving them alone" ); reportGaps(); - return undefined; + return bridgeDispose; } const host = loadPiAi(ctx); if (host === undefined) { @@ -1110,7 +1110,7 @@ export const registerResponsesProvider = async ( "[dsh-opencode-patch] no loaded llm-pi-ai in this composition; internal routes stay unregistered" ); reportGaps(); - return undefined; + return bridgeDispose; } const providers: Record = {}; const mounted: string[] = []; @@ -1166,8 +1166,27 @@ export const registerResponsesProvider = async ( ); return bridgeDispose; } + // Set once the fiber exists, so every exit below can withdraw what the mount + // did register: a route captured before a startup error is live on the host, + // and a fiber nobody disposes keeps it there across every reload. + let fiber: ReturnType | undefined; + const releaseMount = (): void => { + for (const handle of captured) { + releaseRegistration(handle); + } + captured.length = 0; + const owned = fiber; + fiber = undefined; + void (async () => { + try { + await owned?.dispose?.(); + } catch { + // A disposal failure must not mask the plugin's own teardown. + } + })(); + }; try { - const fiber = scope.plugin(host, { providers }); + fiber = scope.plugin(host, { providers }); // Cordis's `Fiber` is not thenable; `await()` is what waits for the // lifecycle work and rethrows a startup error. A stand-in may hand over a // promise instead, so both shapes are settled here. @@ -1179,6 +1198,7 @@ export const registerResponsesProvider = async ( ctx.logger?.info?.( "[dsh-opencode-patch] the mount registered no route; internal routes stay unregistered" ); + releaseMount(); return bridgeDispose; } // The line that answers "what actually mounted", which the boot line's @@ -1191,19 +1211,10 @@ export const registerResponsesProvider = async ( return () => { bridgeDispose?.(); - for (const handle of captured) { - releaseRegistration(handle); - } - captured.length = 0; - void (async () => { - try { - await fiber?.dispose?.(); - } catch { - // A disposal failure must not mask the plugin's own teardown. - } - })(); + releaseMount(); }; } catch (error) { + releaseMount(); ctx.logger?.info?.( "[dsh-opencode-patch] internal routes stay unregistered (%s); routes declared in the profile are unaffected", error instanceof Error ? error.message : String(error) diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 9e2c4248..b671ed7d 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveConfig } from "../src/config.ts"; import { apply, type CordisContext, @@ -19,6 +20,7 @@ import { clearSessionUsageStore, RESPONSES_ROUTE, } from "../src/index.ts"; +import { mountInternalRoutes } from "../src/lifecycle.ts"; import { collectUnknown, createMockStream, @@ -259,6 +261,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 = { @@ -1084,3 +1153,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/responses-provider.test.ts b/test/responses-provider.test.ts index 0a0efa65..a004ad21 100644 --- a/test/responses-provider.test.ts +++ b/test/responses-provider.test.ts @@ -24,6 +24,7 @@ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai"; import { describe, expect, it, vi } from "vitest"; import { OPENCODE_PLANE_API } from "../src/catalog-data.ts"; +import { resolveConfig } from "../src/config.ts"; import { RESPONSES_SDK, ROUTE_FOR_PLANE_PROTOCOL, @@ -1808,6 +1809,73 @@ describe("Go Google bridge mounting", () => { expect(host.adapters.has("opencode-go-google")).toBe(false); }); + it("hands back the bridges' disposer when the internal routes are off", async () => { + // The bridge mounts before the switch is read, so an exit that answered + // `undefined` here left both Google routes registered with nothing able to + // withdraw them — and the next reload's bridge then failed DUPLICATE_ADAPTER. + const host = createHost(); + const stop = await registerResponsesProvider( + host.ctx, + resolveConfig({ internalRoutes: false }) + ); + expect(host.adapters.has("opencode-google")).toBe(true); + expect(stop).toBeTypeOf("function"); + stop?.(); + expect(host.adapters.has("opencode-google")).toBe(false); + expect(host.adapters.has("opencode-go-google")).toBe(false); + // And a second activation can mount them again, which is what a reload does. + const again = await registerResponsesProvider( + host.ctx, + resolveConfig({ internalRoutes: false }) + ); + expect(host.adapters.has("opencode-google")).toBe(true); + again?.(); + }); + + it("withdraws routes and disposes the fiber when the mount fails to start", async () => { + // A route the mounted plugin registered before its startup error is live on + // the host. The failure path used to log "internal routes stay unregistered" + // and leave both the route and the fiber behind. + const host = createHost(); + const disposed = vi.fn(); + type Ctx = NonNullable; + const wrap = (ctx: Ctx): Ctx => { + const isolate = ctx.isolate as (name: string) => Ctx; + const plugin = ctx.plugin as ( + subject: unknown, + config?: unknown + ) => unknown; + Object.assign(ctx, { + isolate(this: Ctx, name: string) { + return wrap(isolate.call(this, name)); + }, + plugin(this: Ctx, subject: unknown, config?: unknown) { + plugin.call(this, subject, config); + return { + await: () => Promise.reject(new Error("startup failed")), + dispose: disposed, + }; + }, + }); + return ctx; + }; + wrap(host.ctx); + const stop = await registerResponsesProvider(host.ctx); + // Fiber disposal is fire-and-forget; let it run. + await Promise.resolve(); + expect(disposed).toHaveBeenCalledTimes(1); + // Only the two bridges remain; every route the failed mount took is gone. + expect( + ( + (host.ctx.llm?.listProviders?.() ?? []) as readonly { id: string }[] + ).map((provider) => provider.id) + ).toEqual(["opencode-google", "opencode-go-google"]); + // The bridges did mount, and the disposer handed back still withdraws them. + expect(host.adapters.has("opencode-google")).toBe(true); + stop?.(); + expect(host.adapters.has("opencode-google")).toBe(false); + }); + it("withdraws the first bridge if mounting the second fails", async () => { const host = createHost(); const llm = host.ctx.llm!; From aa3c01b1aaaf870431b16da2fb8b88b538b043f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:40:31 +0000 Subject: [PATCH 03/17] fix: price, list and key requests on the plane they belong to; isolate the suite Request path and meter: - Price a turn on its route's plane. Go-first lookups priced every Zen turn on deepseek-v4-pro at 0.66/1.98 instead of 1.74/3.48. - The listing and model-info patches now act only on the routes this plugin claims. Before, a direct anthropic or openai route lost rows the OpenCode routing called "unservable", and a refused foreign id was answered with OpenCode's catalog. - A relay's Go /models is answered with the Go catalog. The enrichment re-checked the URL against the default gateway markers and fell back to Zen. - The free-model check reads a Request-carried body. That is how DSH sends a turn, and every keyless free turn was reported as a missing credential. The body is now read once for both uses. - The meter's session fallback is the session that most recently ran a turn, not the most recently created one. - A captured key's tier comes from the observed URL, then the route, and never the key's prefix. OpenCode issues oc_sk_ keys for Go, so a working Go key was filed under Zen. An OpenAI sk- key on a claimed relay was filed under Go. Suite isolation: - The unit and mock suites refuse non-loopback requests. Each unit run sent 54 requests to models.opencode.ai through the catalog's background revalidation, so on a networked runner a result could depend on the vendor's live catalog. - Captured keys, session spend and the live catalog are reset after every test (resetCatalogState is new). Shuffled runs failed catalog cases on seeds 42 and 1234. test:shuffle runs the suite in random order, and the Node 24 CI job uses it. Each behaviour change has a test that fails without it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- .github/workflows/ci.yml | 5 ++- package.json | 1 + src/fetch-patch.ts | 45 ++++++++++--------- src/go-discovery.ts | 12 ++--- src/key-capture.ts | 33 ++++++-------- src/models-catalog.ts | 25 ++++++++++- src/models-discovery.ts | 14 +++++- src/session-cost.ts | 10 ++++- src/stream-hook.ts | 9 +++- test/catalog.test.ts | 25 ++++++++++- test/fetch-patch.test.ts | 70 +++++++++++++++++++++++++++++ test/go-discovery.test.ts | 33 +++++++------- test/lifecycle.test.ts | 53 ++++++++++++++++++++++ test/models-discovery.test.ts | 54 ++++++++++++++++++++++ test/node-engine.test.ts | 16 +++++++ test/session-cost.test.ts | 10 +++++ test/support/offline.ts | 54 ++++++++++++++++++++++ test/support/reset-process-state.ts | 30 +++++++++++++ test/usage.test.ts | 50 +++++++++++++++------ vite.config.ts | 8 ++++ vitest.e2e.config.ts | 2 + 21 files changed, 472 insertions(+), 87 deletions(-) create mode 100644 test/support/offline.ts create mode 100644 test/support/reset-process-state.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0914c5b..6db0aa08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,10 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm run test + # 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 diff --git a/package.json b/package.json index edb6cf27..5438b158 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "test:coverage": "vp test run --coverage", "test:e2e": "vp test --config vitest.e2e.config.ts", "test:mock": "vp test --config vitest.mock.config.ts", + "test:shuffle": "vp test run --sequence.shuffle", "typecheck": "tsc --noEmit", "cli:version": "node --experimental-strip-types scripts/check-vendor-version.ts", "version:patch": "vp pm version patch --no-git-checks", diff --git a/src/fetch-patch.ts b/src/fetch-patch.ts index 272d468a..e2710fbd 100644 --- a/src/fetch-patch.ts +++ b/src/fetch-patch.ts @@ -198,6 +198,25 @@ export const patchFetch = ( const headers = new Headers(headerSourceOf(input, init)); + // The body, read once, off whichever half carries it. DSH sends inference as + // fetch(new Request(url, { body })) — so init.body is undefined, and two + // things read it: the free-model check on the credential path below and the + // core-tool fallback after it. The free-model check used to read + // `init.body` alone, so for every DSH turn it answered "not free" and a + // keyless free model was reported as a missing credential. + let bodySource: "request" | "init" | "none" = "none"; + let bodyToPatch: RequestInit["body"] = init?.body; + if (bodyToPatch !== undefined) { + bodySource = "init"; + } else if (input instanceof Request) { + try { + bodyToPatch = await input.clone().text(); + bodySource = "request"; + } catch { + bodyToPatch = undefined; + } + } + // Capture the API key from the request headers so that quota monitoring, // usage display, and key resolution don't have to rely purely on static env vars. const extracted = extractApiKeyFromHeaders(headers); @@ -291,11 +310,9 @@ export const patchFetch = ( // an anonymous request anyway, so stripping it here would change nothing // but would also deprive a keyless-but-paid model of a header to fill. // - // `init.body` is read, not the `Request`: the tool-injection block below - // resolves a `Request`-carried body, and this check runs before it. A - // body the switch cannot see answers "not free", which is the safe + // A body the check cannot read answers "not free", which is the safe // side - the warning is the status quo, not a new failure. - !isFreeModelBody(init?.body) + !isFreeModelBody(bodyToPatch) ) { warnedNoCredential.add(plane); const sources: string[] = [ @@ -404,24 +421,8 @@ export const patchFetch = ( const newInit: RequestInit = { ...init, headers }; let requestToSend = input; - // 4. Core tool schema fallback for free-tier /responses models. - // - // DSH sends inference as fetch(new Request(url, { body })) — so init.body - // is undefined and the old `init?.body !== undefined` guard skipped the - // whole block: Agent Team turns left with no bash and the gateway 403s - // the body as non-OpenCode. Read the body off whichever half carries it. - let bodySource: "request" | "init" | "none" = "none"; - let bodyToPatch: RequestInit["body"] = init?.body; - if (bodyToPatch !== undefined) { - bodySource = "init"; - } else if (input instanceof Request) { - try { - bodyToPatch = await input.clone().text(); - bodySource = "request"; - } catch { - bodyToPatch = undefined; - } - } + // 4. Core tool schema fallback for free-tier /responses models, over the + // body read above. let finalBody = bodyToPatch; if (bodyToPatch !== undefined) { const newBody = maybeInjectCoreTools(url, bodyToPatch, headers, { diff --git a/src/go-discovery.ts b/src/go-discovery.ts index 4de611ca..06af45fa 100644 --- a/src/go-discovery.ts +++ b/src/go-discovery.ts @@ -582,16 +582,12 @@ export const resolveRoutedKey = async ( ), }); - // Inverse precedence of `recordCapturedApiKey`: here the routed provider is - // authoritative, because the resolved key may be a shared or fallback - // credential whose prefix says nothing about the route in use. + // The routed provider decides, and the key's prefix never does: OpenCode + // issues `oc_sk_…` for Go credentials too (see `tierForRequest`). let tier: "go" | "zen" | "unknown" = "unknown"; - if (isGoRoute || (key !== undefined && key.startsWith("sk-"))) { + if (isGoRoute) { tier = "go"; - } else if ( - provider === "opencode" || - (key !== undefined && key.startsWith("oc_sk_")) - ) { + } else if (provider === "opencode") { tier = "zen"; } diff --git a/src/key-capture.ts b/src/key-capture.ts index c6edb24b..0d9dd0da 100644 --- a/src/key-capture.ts +++ b/src/key-capture.ts @@ -54,33 +54,26 @@ export const isPlaceholderApiKey = (key: string): boolean => /** * Which account tier a request belongs to. * - * Ordered by how much each signal can be trusted, which is the whole point: + * Ordered by how much each signal can be trusted: * - * 1. The **key prefix** is intrinsic to the credential, so it survives a rotated - * key and a renamed provider route alike. - * 2. The **URL** is what the adapter actually called — observed, not configured. - * 3. The **provider route id** is only a hint. It is user-defined config and may + * 1. The **URL** is what the adapter actually called — observed, not configured. + * 2. The **provider route id** is only a hint. It is user-defined config and may * be named anything (`zen`, `oc`, `my-opencode`), so it must never be the sole * reason a lookup succeeds — that would make a captured, working key * invisible the moment the user renamed a route. * + * **The key's prefix is not a signal**, and it used to be the first one. It was + * measured wrong: OpenCode issues `oc_sk_…` for Go credentials too, and such a + * key answers the Go quota endpoint with real windows (`docs/engineering-notes.md`, + * "A credential prefix is not an entitlement"). Read first, it filed a working + * Go key under Zen — invisible to the meter, and injected into Zen requests. + * And `sk-…` is not OpenCode's to claim: an OpenAI key on a relay the + * deployment routes through here was filed as a Go key. + * * @param url - the request URL, when the caller has one. * @param provider - the routed provider id, when known. - * @param key - the credential itself, when known; its prefix is the best signal. */ -export const tierForRequest = ( - url?: string, - provider?: string, - key?: string -): KeyTier => { - if (typeof key === "string" && key.length > 0) { - if (key.startsWith("sk-")) { - return "go"; - } - if (key.startsWith("oc_sk_")) { - return "zen"; - } - } +export const tierForRequest = (url?: string, provider?: string): KeyTier => { if (typeof url === "string" && url.length > 0) { // Both endpoint families, because the gateway answers on both and a request // on either belongs to one of the two accounts. Matching only the legacy @@ -222,7 +215,7 @@ export const recordCapturedApiKey = ( // Deliberately NOT `resolveRoutedKey`'s precedence: a key seen on a live // request is classified by the request that carried it, not by the route the // user named. See `tierForRequest`. - const tier = tierForRequest(url, provider, key); + const tier = tierForRequest(url, provider); if (typeof provider === "string" && provider.length > 0) { capturedKeysByProvider.set(provider, { key, tier }); diff --git a/src/models-catalog.ts b/src/models-catalog.ts index dae9c5ea..642de652 100644 --- a/src/models-catalog.ts +++ b/src/models-catalog.ts @@ -145,6 +145,25 @@ let refreshPromise: }> | undefined; +/** + * Put the catalog back to the bundled shim, as a fresh process holds it. + * + * The live maps, the retired-id sets and the refresh bookkeeping are module + * state for the life of the process, which is right for the host and wrong for + * a test: a case that refreshes from a fixture would otherwise decide what every + * later case in the file sees. The sibling resets are `clearCapturedApiKeys` and + * `clearSessionUsageStore`. + */ +export const resetCatalogState = (): void => { + activeGoCatalog = new Map(OPENCODE_GO_CATALOG.map((m) => [m.id, m])); + activeZenCatalog = new Map(OPENCODE_ZEN_CATALOG.map((m) => [m.id, m])); + deprecatedGoIds.clear(); + deprecatedZenIds.clear(); + lastRefreshedAt = 0; + lastRefreshSucceeded = false; + refreshPromise = undefined; +}; + export interface ParsedCatalogs { /** * The endpoint each plane's provider declares, read from the catalog itself @@ -696,7 +715,11 @@ export const enrichModelsResponse = async ( // If the upstream returned non-JSON, generate the full catalog response. } - const isGo = isGoModelsListingUrl(url); + // The plane from the PATH alone. The caller has already decided this is a + // listing, against the deployment's own gateway markers; asking + // `isGoModelsListingUrl` again applied the DEFAULT markers, so a relay's + // `/zen/go/v1/models` was answered with the Zen catalog. + const isGo = isGoPlaneUrl(url); const catalog = isGo ? getLiveGoCatalog(fetchFn) : getLiveZenCatalog(fetchFn); const catalogById = new Map(catalog.map((m) => [m.id, m])); const merged = new Map>(); diff --git a/src/models-discovery.ts b/src/models-discovery.ts index fa0ef70a..d5e4a731 100644 --- a/src/models-discovery.ts +++ b/src/models-discovery.ts @@ -451,7 +451,12 @@ export const hideResponsesRoute = ( return []; } const models = await Reflect.apply(originalListModels, this, [provider]); - if (!Array.isArray(models)) { + // **Only the routes this plugin claims.** A provider the deployment did + // not hand it — a direct `anthropic` or `openai` route — is answered by its + // own adapter and nothing else: the servability rule below reads OUR + // catalog, and against a foreign route it dropped real rows (a new Claude + // or GPT id has no OpenCode route, so it read as "unservable"). + if (!Array.isArray(models) || !config.providers.has(provider)) { return models; } // **An empty answer from a route that DECLARES models is a credential @@ -1072,6 +1077,13 @@ export const decorateModelInfo = ( model: string, signal?: AbortSignal ): Promise => { + // A provider this plugin does not claim is answered by its own adapter, + // unchanged and un-caught. Our catalog is OpenCode's: substituting it for a + // foreign route's failure, or overwriting a foreign route's limits with it, + // describes a model the user is not calling. + if (!config.providers.has(provider) && !isInternalRoute(provider)) { + return Reflect.apply(original, llm, [provider, model, signal]); + } let resolved: unknown; try { resolved = await Reflect.apply(original, llm, [provider, model, signal]); diff --git a/src/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/stream-hook.ts b/src/stream-hook.ts index 710f8857..dcc39b4a 100644 --- a/src/stream-hook.ts +++ b/src/stream-hook.ts @@ -349,7 +349,14 @@ export const createStreamHook = ( } const modelProp: unknown = options.model; const modelName = typeof modelProp === "string" ? modelProp : "unknown"; - const spec = findModelSpec(modelName); + // The ROUTE's plane first, as the dispatch lookup above does: the two planes + // price some shared ids differently (deepseek-v4-pro is 0.66/1.98 on Go and + // 1.74/3.48 on Zen), and a Go-first lookup recorded every Zen turn on those + // at the Go rate. The Go-first lookup is the fallback for a model neither + // route's plane carries. + const spec = + findModelSpecOn(catalogPlaneForRoute(providerKey), modelName) ?? + findModelSpec(modelName); const wrappedStream = async function* wrappedStream() { for await (const chunk of downstream) { diff --git a/test/catalog.test.ts b/test/catalog.test.ts index a95579b3..f55281a6 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -35,7 +35,7 @@ import { sanitizeModalities, type ActiveTurnState, } from "../src/index.ts"; -import { findModelSpec } from "../src/models-catalog.ts"; +import { findModelSpec, resetCatalogState } from "../src/models-catalog.ts"; import { UNSERVED_PLANE_SDKS } from "../src/responses-routes.ts"; import { createCaptureFetch, headerOf, SESSION_RE } from "./test-helpers.ts"; @@ -723,6 +723,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, diff --git a/test/fetch-patch.test.ts b/test/fetch-patch.test.ts index 8be3e5d8..e0d9905c 100644 --- a/test/fetch-patch.test.ts +++ b/test/fetch-patch.test.ts @@ -18,6 +18,7 @@ import { SESSION_HEADER, clearCapturedApiKeys, getCapturedApiKey, + getLiveGoCatalog, recordCapturedApiKey, openCodeSessionIdFor, patchFetch, @@ -137,6 +138,75 @@ describe("patchFetch", () => { ).toHaveLength(0); }); + it("reads the free model off a Request body, which is how DSH sends a turn", async () => { + // DSH calls fetch(new Request(url, { body })), so `init` carries no body. + // The check used to read `init.body` alone and reported every keyless free + // turn as a missing credential. + const als = new AsyncLocalStorage(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const ok = vi.fn(async () => Response.json({ ok: true })); + const patched = patchFetch( + ok as unknown as typeof fetch, + als, + resolveConfig(), + {} + ); + await patched( + new Request("https://opencode.ai/zen/v1/chat/completions", { + body: JSON.stringify({ messages: [], model: "beam-free" }), + method: "POST", + }) + ); + const freeLines = warn.mock.calls + .map((args) => args.map(String).join(" ")) + .filter((text) => text.includes("no credential resolved")); + expect(freeLines).toHaveLength(0); + + // The same keyless Request for a PAID model is still reported, so the + // case is not passing because the warning went quiet altogether. + await patched( + new Request("https://opencode.ai/zen/v1/chat/completions", { + body: JSON.stringify({ messages: [], model: "gpt-5.4" }), + method: "POST", + }) + ); + expect( + warn.mock.calls + .map((args) => args.map(String).join(" ")) + .some((text) => text.includes("no credential resolved for opencode")) + ).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + it("answers a relay's Go listing with the Go catalog", async () => { + // The listing is claimed against the deployment's own markers. The + // enrichment then re-asked with the DEFAULT markers, so a relay's Go + // listing was filled from the Zen catalog. + const als = new AsyncLocalStorage(); + const upstream = vi.fn(async () => Response.json({ data: [] })); + const patched = patchFetch( + upstream as unknown as typeof fetch, + als, + resolveConfig({ gatewayUrls: ["relay.example/zen"] }), + {} + ); + const response = await patched("https://relay.example/zen/go/v1/models", { + headers: { authorization: "Bearer test-go-key" }, + method: "GET", + }); + const body = (await response.json()) as { data: { id: string }[] }; + const ids = body.data.map((row) => row.id).toSorted(); + expect(ids.length).toBeGreaterThan(0); + expect(ids).toEqual( + getLiveGoCatalog() + .map((spec) => spec.id) + .toSorted() + ); + }); + it("does not name the sources when a credential does resolve", async () => { const als = new AsyncLocalStorage(); const warnings: unknown[][] = []; diff --git a/test/go-discovery.test.ts b/test/go-discovery.test.ts index dd6087b1..35429290 100644 --- a/test/go-discovery.test.ts +++ b/test/go-discovery.test.ts @@ -947,21 +947,17 @@ describe("resolveRoutedKey", () => { expect(routed.provider).toBe("opencode-go"); }); - it("classifies a renamed route by the key prefix alone", async () => { - // The prefix is intrinsic to the credential, so it survives a route rename; - // a route id says nothing about the account behind it. - recordCapturedApiKey("sk-rotated-key", "my-relay", "https://relay.test/v1"); - const byGoPrefix = await resolveRoutedKey(composition(), "my-relay"); - expect(byGoPrefix.tier).toBe("go"); - clearCapturedApiKeys(); - recordCapturedApiKey("oc_sk_zen-key", "my-relay", "https://relay.test/v1"); - const byZenPrefix = await resolveRoutedKey(composition(), "my-relay"); - expect(byZenPrefix.tier).toBe("zen"); - clearCapturedApiKeys(); - recordCapturedApiKey("opaque-token", "my-relay", "https://relay.test/v1"); - const unknown = await resolveRoutedKey(composition(), "my-relay"); - expect(unknown.tier).toBe("unknown"); - expect(unknown.key).toBe("opaque-token"); + it("does not classify a renamed route by the key's prefix", async () => { + // The route decides, and a renamed route names no plane. The prefix used to + // decide instead, and it is not OpenCode's: `oc_sk_` is issued for Go keys + // too, and `sk-` is every OpenAI key. + for (const key of ["sk-rotated-key", "oc_sk_zen-key", "opaque-token"]) { + clearCapturedApiKeys(); + recordCapturedApiKey(key, "my-relay", "https://relay.test/v1"); + const routed = await resolveRoutedKey(composition(), "my-relay"); + expect(routed.key).toBe(key); + expect(routed.tier).toBe("unknown"); + } }); it("accepts a Zen key for a route that legitimately owns one", async () => { @@ -976,13 +972,14 @@ describe("resolveRoutedKey", () => { ).toBeUndefined(); }); - it("takes the most recent captured key when the route has none of its own", async () => { + it("reports the tier of the route it was asked about", async () => { // A non-Go route is not tier-filtered, so a key captured elsewhere can - // answer for it — which is why the reported tier comes from the key. + // answer for it; the tier reported is still the route's, never a guess + // from the key's shape. recordCapturedApiKey("sk-go-key-value", "opencode-go", GO_REQUEST); const routed = await resolveRoutedKey(composition(), "opencode"); expect(routed.key).toBe("sk-go-key-value"); - expect(routed.tier).toBe("go"); + expect(routed.tier).toBe("zen"); }); it("uses the route's own declared reference, defaulting each plane differently", async () => { diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index b671ed7d..448fbae6 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -21,6 +21,7 @@ import { RESPONSES_ROUTE, } from "../src/index.ts"; import { mountInternalRoutes } from "../src/lifecycle.ts"; +import { findModelSpecOn } from "../src/models-catalog.ts"; import { collectUnknown, createMockStream, @@ -853,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) diff --git a/test/models-discovery.test.ts b/test/models-discovery.test.ts index 228aff34..2fa6706c 100644 --- a/test/models-discovery.test.ts +++ b/test/models-discovery.test.ts @@ -362,6 +362,28 @@ describe("models-discovery · an empty list from a route that declares models", stop?.(); }); + it("passes a provider it does not claim through untouched", async () => { + // A direct `anthropic` or `openai` route is the deployment's, not this + // plugin's. Its ids are not in the OpenCode catalog's routing, and the + // servability filter used to drop them as "unservable". + const foreign = [ + { id: "claude-opus-9", name: "Claude Opus 9", provider: "anthropic" }, + { id: "gpt-7", name: "GPT-7", provider: "openai" }, + ]; + const ctx = { + llm: { + listProviders: () => [], + listConfigurableProviders: () => [], + listModels: async (provider: string) => + foreign.filter((row) => row.provider === provider), + }, + } as unknown as CordisContext; + const stop = hideResponsesRoute(ctx, resolveConfig()); + expect(await ctx.llm?.listModels?.("anthropic")).toEqual([foreign[0]]); + expect(await ctx.llm?.listModels?.("openai")).toEqual([foreign[1]]); + stop?.(); + }); + it("leaves an adapter's own answer alone", async () => { const ctx = { llm: { @@ -1102,6 +1124,38 @@ describe("models-discovery: the Effort row a model reports", () => { stop?.(); }); + it("leaves a provider it does not claim to its own adapter", async () => { + // The catalog is OpenCode's. A foreign route whose adapter refuses an id the + // catalog happens to share must keep its own failure, and one that answers + // must keep its own limits rather than OpenCode's. + const own = { + context: { contextWindow: 200_000 }, + id: "claude-sonnet-4-5", + }; + const ctx = { + llm: { + resolveModelInfo: async (provider: string) => { + if (provider === "anthropic-broken") { + throw new Error("adapter refused"); + } + return own; + }, + }, + } as never as CordisContext; + const stop = decorateModelInfo(ctx, enrichConfig); + await expect( + patched(ctx)("anthropic-broken", "claude-sonnet-4-5") + ).rejects.toThrow("adapter refused"); + expect(await patched(ctx)("anthropic", "claude-sonnet-4-5")).toBe(own); + // The claimed route still answers from the catalog, so the guard is not + // simply switching the decorator off. + const claimed = (await patched(ctx)("opencode", "claude-sonnet-4-5")) as { + context?: { contextWindow?: number }; + }; + expect(claimed.context?.contextWindow).not.toBe(200_000); + stop?.(); + }); + it("turns a NON-RECORD answer into a catalog record, so the group survives", async () => { // The quieter half of the same hazard. The Host reads `resolved.reasoning` // immediately after `resolveModelInfo` returns, so an `undefined` answer is a diff --git a/test/node-engine.test.ts b/test/node-engine.test.ts index 33e749f8..918cbf10 100644 --- a/test/node-engine.test.ts +++ b/test/node-engine.test.ts @@ -60,3 +60,19 @@ describe("node-engine guard", () => { 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/session-cost.test.ts b/test/session-cost.test.ts index e7fc45b6..62089062 100644 --- a/test/session-cost.test.ts +++ b/test/session-cost.test.ts @@ -261,6 +261,16 @@ describe("getSessionUsage", () => { expect(getSessionUsage("nope")?.totalTokens).toBe(9); }); + it("falls back to the session that ran a turn most recently, not the newest one", () => { + // The meter asks with no id, so this fallback IS its reading. Ordered by + // creation, a turn in an older session kept showing the newer one's spend. + recordTurnUsage("a", { inputTokens: 1, totalTokens: 1 }, GO_FLASH, "m"); + recordTurnUsage("b", { inputTokens: 9, totalTokens: 9 }, GO_FLASH, "m"); + expect(getSessionUsage()?.totalTokens).toBe(9); + recordTurnUsage("a", { inputTokens: 1, totalTokens: 1 }, GO_FLASH, "m"); + expect(getSessionUsage()?.totalTokens).toBe(2); + }); + it("returns a copy that cannot mutate stored state", () => { recordTurnUsage("a", { inputTokens: 1, totalTokens: 1 }, GO_FLASH, "m"); const first = getSessionUsage("a"); diff --git a/test/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/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/usage.test.ts b/test/usage.test.ts index fee756f2..be81b861 100644 --- a/test/usage.test.ts +++ b/test/usage.test.ts @@ -419,23 +419,47 @@ describe("OpenCode Go Usage", () => { }); describe("tierForRequest", () => { - it("trusts the key prefix above every configured signal", () => { - // The prefix is intrinsic to the credential, so it survives both a - // rotated key and a route the user renamed. + it("never reads the key's prefix", () => { + // Measured: OpenCode issues `oc_sk_…` for Go credentials, so the prefix + // names no tier. The observed URL decides, and the route after it. expect( tierForRequest( - "https://opencode.ai/zen/v1/messages", - "opencode", - "sk-x" + "https://opencode.ai/zen/go/v1/chat/completions", + "opencode-go" ) ).toBe("go"); + // The signature no longer takes the key, so it cannot be consulted. + expect(tierForRequest.length).toBe(2); + }); + + it("files an oc_sk_ key seen on the Go plane under Go", () => { + // The reproduction: read prefix-first, this key was filed under Zen. The + // meter's Go lookup then missed it, and a Zen request carrying a + // placeholder was handed the Go key. + recordCapturedApiKey( + "oc_sk_GOKEY_fixture", + "opencode-go", + "https://opencode.ai/zen/go/v1/chat/completions" + ); + expect(getCapturedApiKey("opencode-go", "go")).toBe( + "oc_sk_GOKEY_fixture" + ); + expect(getCapturedApiKey(undefined, "zen")).toBeUndefined(); + }); + + it("does not claim a foreign sk- key for the Go account", () => { + // An OpenAI key on a relay a deployment routes through the patch. Read by + // prefix it was filed as Go and offered to every Go request. + recordCapturedApiKey( + "sk-openai-fixture", + "my-relay", + "https://relay.example/v1/chat/completions" + ); expect( - tierForRequest( - "https://opencode.ai/zen/go/v1/chat/completions", - "opencode-go", - "oc_sk_x" - ) - ).toBe("zen"); + tierForRequest("https://relay.example/v1/chat/completions", "my-relay") + ).toBe("unknown"); + // Unknown stays answerable by route, as before, but is not filed by tier. + expect(getCapturedApiKey("my-relay")).toBe("sk-openai-fixture"); }); it("trusts the URL above the provider route id", () => { @@ -531,7 +555,7 @@ describe("OpenCode Go Usage", () => { "unknown" ); expect(tierForRequest()).toBe("unknown"); - expect(tierForRequest("", "", "")).toBe("unknown"); + expect(tierForRequest("", "")).toBe("unknown"); }); }); diff --git a/vite.config.ts b/vite.config.ts index 6c242696..97ea0cb1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -173,6 +173,14 @@ export default defineConfig({ // 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, From 54a5b1941f910bf01cd91298eb06588716b88a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:43:58 +0000 Subject: [PATCH 04/17] fix(store): mount the inspection remote, and read legacy keys per integration - The card's store-inspection row called remote.opencodeStore.inspect, but no client contribution carried that namespace's descriptor. The row reported "unavailable" on a host that had the service. storeRemote now carries it, with a whitelist codec, and the client mounts it. - The host/client parity test missed this because it matched only a string literal in super(ctx, "name"), and the store passes a constant. It now resolves constants, and checks every declared Remote method against a client descriptor, not just every namespace. - inspectStore parses through the whitelist instead of a cast, so a field the host did not mean to send (a token) is dropped. An ok:false envelope reports the remote's own message. - The credential reader's legacy-row fallback was uncorrelated: one flagged opencode-go row hid every legacy opencode row, and the Zen key disappeared. The fallback is now per integration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- src/opencode-store-contract.ts | 106 +++++++++++++++++++++++++++++++++ src/opencode-store.ts | 14 +++-- src/settings-page.tsx | 13 +++- src/settings-store.ts | 23 +++++-- test/opencode-store.test.ts | 19 ++++++ test/remote-contract.test.ts | 101 +++++++++++++++++++++++++++---- test/settings-page.test.tsx | 5 +- test/settings-store.test.ts | 37 ++++++++++++ 8 files changed, 290 insertions(+), 28 deletions(-) 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/settings-page.tsx b/src/settings-page.tsx index e66c1e76..195baca8 100644 --- a/src/settings-page.tsx +++ b/src/settings-page.tsx @@ -15,6 +15,7 @@ import { } from "@deepseek-ai/dsh-client-ui-primitives"; import { DEFAULT_PROVIDERS, readStringList } from "./config-values.ts"; +import { storeRemote } from "./opencode-store-contract.ts"; import { purgeRemote } from "./purge-contract.ts"; import { OpencodeCard } from "./settings-card.tsx"; import { en, withBundleCopy, zh, type CopyKey } from "./settings-copy.ts"; @@ -47,14 +48,20 @@ export const PKG = "dsh-opencode-patch"; * * It is a contribution rather than a bare descriptor list because Typert * enforces unique package names, and this is the one place the package is - * registered. The meter's namespace is the only one this build mounts — the - * sign-in row and its `opencodeLogin` namespace are v2 work. + * registered. It carries every namespace the host mounts — the meter, the purge + * and the store inspection — and `test/remote-contract.test.ts` fails when a + * host service declares a method this list does not. The sign-in row and its + * `opencodeLogin` namespace are v2 work. */ export const patchRemote = { // ONE contribution, TWO namespaces. Typert enforces a unique package name per // contribution, so mounting them separately with the same package throws // "already registered" and drops the remote entirely. - descriptors: [...usageRemote.descriptors, ...purgeRemote.descriptors], + descriptors: [ + ...usageRemote.descriptors, + ...purgeRemote.descriptors, + ...storeRemote.descriptors, + ], package: PKG, }; diff --git a/src/settings-store.ts b/src/settings-store.ts index c4d1245b..e9df08e9 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -8,6 +8,7 @@ */ import { + parseStoreInspection, STORE_NAMESPACE, type OpencodeStoreInspection, } from "./opencode-store-contract.ts"; @@ -42,18 +43,28 @@ export const inspectStore = async ( } try { const answer: unknown = await call(); + if (isRecord(answer) && answer.ok === false) { + // The remote's own failure, said in its own words rather than as a shape + // error about the envelope it arrived in. + const { error } = answer; + return { + kind: "failed", + message: + isRecord(error) && typeof error.message === "string" + ? error.message + : "the host could not inspect the installation", + }; + } const value = isRecord(answer) && "value" in answer ? answer.value : answer; - if (!isRecord(value) || typeof value.found !== "boolean") { + const inspection: OpencodeStoreInspection | undefined = + parseStoreInspection(value); + if (inspection === undefined) { return { kind: "failed", message: "host returned an invalid inspection shape", }; } - return { - kind: "inspected", - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - inspection: value as unknown as OpencodeStoreInspection, - }; + return { inspection, kind: "inspected" }; } catch (error: unknown) { return { kind: "failed", diff --git a/test/opencode-store.test.ts b/test/opencode-store.test.ts index 551add3d..977f912c 100644 --- a/test/opencode-store.test.ts +++ b/test/opencode-store.test.ts @@ -125,6 +125,25 @@ describe("opencode-store: credentials", () => { expect(found?.[0]?.value).toBe("oc_sk_fixture_new"); }); + it("falls back to legacy rows per integration, not across the table", () => { + // A flagged Go row is the Go selection only. Read across the table, it hid + // the legacy Zen row and the reader answered with no Zen key at all. + const path = database((db) => { + schema(db); + addCredential(db, "go", "opencode-go", "oc_sk_fixture_go", 1); + db.prepare( + "INSERT INTO credential (id, integration_id, label, value, active, time_updated) VALUES (?, ?, ?, ?, NULL, ?)" + ).run("legacy", "opencode", "Zen", "oc_sk_fixture_zen_legacy", 100); + }); + const found = readOpencodeCredentials(path); + expect( + found?.map((row) => `${row.integrationId}=${row.value}`).toSorted() + ).toEqual([ + "opencode-go=oc_sk_fixture_go", + "opencode=oc_sk_fixture_zen_legacy", + ]); + }); + it("extracts the key from the JSON every real row carries", () => { // Measured against a live installation: EVERY value is a JSON document, not a // bare key. Returning the document puts JSON in the Bearer header, and the diff --git a/test/remote-contract.test.ts b/test/remote-contract.test.ts index 5d3e9884..7c3eb209 100644 --- a/test/remote-contract.test.ts +++ b/test/remote-contract.test.ts @@ -24,35 +24,110 @@ import { patchRemote } from "../src/settings-page.tsx"; const SRC = join(import.meta.dirname, "..", "src"); +/** Every `export const NAME = "value"` in `src/`, so a constant resolves. */ +const exportedStrings = (): Map => { + const constants = new Map(); + for (const file of readdirSync(SRC).filter((name) => name.endsWith(".ts"))) { + const source = readFileSync(join(SRC, file), "utf8"); + for (const match of source.matchAll( + /export const ([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]+)"/gu + )) { + if (match[1] !== undefined && match[2] !== undefined) { + constants.set(match[1], match[2]); + } + } + } + return constants; +}; + +/** One remote service the host mounts: its file, class and namespace. */ +interface HostService { + readonly file: string; + readonly className: string; + readonly namespace: string; +} + /** - * Every namespace the host mounts a remote service under. + * Every remote service the host mounts, with the namespace it mounts under. * * Read from the source rather than imported: the services publish their - * namespace through `super(ctx, "name")`, and instantiating them needs a cordis - * context. A grep over the tree is the honest measurement of "what does the host - * mount", and it fails loudly if the pattern ever changes. + * namespace through `super(ctx, name)`, and instantiating them needs a cordis + * context. The name may be a literal or an exported constant, and both resolve: + * matching only the literal let `opencodeStore` — passed as `STORE_NAMESPACE` — + * through with no client descriptor, and the card's inspection row could never + * answer. */ -const hostNamespaces = (): string[] => { - const found = new Set(); +const hostServices = (): HostService[] => { + const constants = exportedStrings(); + const services: HostService[] = []; for (const file of readdirSync(SRC).filter((name) => name.endsWith(".ts"))) { const source = readFileSync(join(SRC, file), "utf8"); - for (const match of source.matchAll( - /super\(\s*ctx\s+as\s+never\s*,\s*"([^"]+)"\s*\)/gu - )) { - if (match[1] !== undefined) { - found.add(match[1]); - } + const className = /export class (\w+) extends TypertRemoteService/u.exec( + source + )?.[1]; + const argument = + /super\(\s*ctx\s+as\s+never\s*,\s*("([^"]+)"|[A-Z_][A-Z0-9_]*)\s*\)/u.exec( + source + ); + if (className === undefined || argument === null) { + continue; } + const namespace = + argument[2] ?? + constants.get(argument[1] ?? "") ?? + `unresolved:${argument[1]}`; + services.push({ className, file, namespace }); } - return [...found].toSorted(); + return services.toSorted((a, b) => a.namespace.localeCompare(b.namespace)); }; +const hostNamespaces = (): string[] => + hostServices().map((service) => service.namespace); + +/** The prototype key `remoteMethods()` reads, as the services spell it. */ +const REMOTE_METHOD_DESCRIPTOR_KEY = + "@deepseek-ai/dsh-typert-protocol/remote-methods"; + describe("remote contract · host and client agree", () => { it("finds the host namespaces, so the check is not vacuous", () => { // A grep that matches nothing is indistinguishable from a clean result; // these two are the services this plugin mounts today. expect(hostNamespaces()).toContain("opencodeGoUsage"); expect(hostNamespaces()).toContain("opencodeModelPurge"); + expect(hostNamespaces()).toContain("opencodeStore"); + expect( + hostNamespaces().filter((name) => name.startsWith("unresolved:")) + ).toEqual([]); + }); + + it("registers a descriptor for every Remote METHOD each service declares", async () => { + // Per method, not per namespace: a namespace with one descriptor and a + // second declared method would pass the namespace check and still leave + // the second unreachable. + const declared = new Set( + patchRemote.descriptors.map((d) => `${d.service}.${d.method}`) + ); + const missing: string[] = []; + let methods = 0; + for (const service of hostServices()) { + const module = (await import(join(SRC, service.file))) as Record< + string, + { prototype: Record } + >; + const marker = module[service.className]?.prototype[ + REMOTE_METHOD_DESCRIPTOR_KEY + ] as { methods?: readonly { method: string }[] } | undefined; + for (const { method } of marker?.methods ?? []) { + methods += 1; + if (!declared.has(`${service.namespace}.${method}`)) { + missing.push(`${service.namespace}.${method}`); + } + } + } + // Every service declares at least one method, so a marker read that found + // nothing would show here rather than as a clean pass. + expect(methods).toBeGreaterThanOrEqual(hostServices().length); + expect(missing).toEqual([]); }); it("registers a descriptor for every namespace the host mounts", () => { diff --git a/test/settings-page.test.tsx b/test/settings-page.test.tsx index 61bf310d..0258994d 100644 --- a/test/settings-page.test.tsx +++ b/test/settings-page.test.tsx @@ -1314,9 +1314,10 @@ describe("settings-page: every remote gets a mount", () => { const services = mounted.flatMap((contribution) => contribution.descriptors.map((d) => d.service) ); - expect(services).toHaveLength(2); + // `opencodeStore` is the inspection row's; it was the one left out. + expect(services).toHaveLength(3); expect(new Set(services)).toEqual( - new Set(["opencodeGoUsage", "opencodeModelPurge"]) + new Set(["opencodeGoUsage", "opencodeModelPurge", "opencodeStore"]) ); expect(mounted).toHaveLength(1); expect(mounted[0]?.package).toBe("dsh-opencode-patch"); diff --git a/test/settings-store.test.ts b/test/settings-store.test.ts index 8ff980d8..ab9517c2 100644 --- a/test/settings-store.test.ts +++ b/test/settings-store.test.ts @@ -58,6 +58,43 @@ describe("inspectStore", () => { }); }); + it("keeps only the whitelisted fields, so a stray secret cannot reach the card", async () => { + // The seam's rule is "a credential never crosses it". The parser is what + // enforces that on this side: a field the host did not mean to send is + // dropped, not rendered. + const answer = { + account: { + accessToken: "fixture-token-not-real", + email: "someone@example.com", + type: "oauth", + }, + found: true, + token: "fixture-token-not-real", + }; + const outcome = await inspectStore(scopeWith(async () => answer)); + expect(outcome).toEqual({ + inspection: { + account: { email: "someone@example.com", type: "oauth" }, + found: true, + }, + kind: "inspected", + }); + expect(JSON.stringify(outcome)).not.toContain("fixture-token-not-real"); + }); + + it("reports the remote's own failure from an ok:false envelope", async () => { + // Read as a shape, this used to surface as "invalid inspection shape" — a + // message about the envelope instead of the host's reason. + const inspect = vi.fn(async () => ({ + error: { code: "opencode/store-unavailable", message: "no sqlite here" }, + ok: false, + })); + await expect(inspectStore(scopeWith(inspect))).resolves.toEqual({ + kind: "failed", + message: "no sqlite here", + }); + }); + it("rejects an answer that is not an inspection", async () => { // A shape check, not a wish: the card renders rows off this value, and a // half-built answer would print `undefined` beside a label. From 8a5e95f001b52fe7c60137e0189a465f709b7ec2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:47:03 +0000 Subject: [PATCH 05/17] docs: describe the tree that exists, and the guarantees the suite now keeps - AGENTS.md's repo map described nine modules this build does not have (the Console login, token store, org cache and sign-in row). They are replaced with one line saying sign-in is v2 work. The go-limits duplication note is gone, because attachAllowance calls allowanceFor. adapter-replacement.ts is marked as not wired. The client-bundle line names the boundary test. - CONTRIBUTING.md documents the engine floor, test:shuffle and test:mock, the offline guard, the per-test state reset, where host-only fixtures live, and that a regression test must fail without its fix. - The no-credential warning no longer tells the reader to "sign in through the settings card", which this build does not have. It points at readOpencodeStore instead, and a test asserts the advice. - CLIENT_BUDGET is raised from 128 to 132 KB for the store descriptor and its parser (about 1.2 KB of source, not a dependency), following the gate's own instruction. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- AGENTS.md | 19 +++++++------------ CONTRIBUTING.md | 8 +++++++- scripts/check.ts | 8 +++++++- src/fetch-patch.ts | 2 +- test/fetch-patch.test.ts | 3 +++ 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c936af63..1022cade 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,10 +83,10 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f **The general form, because this shape recurred twice in one session**: a sweep that resolves a name in the wrong CONTEXT reports a clean tree as broken. Grepping for callers without the defining file makes same-file calls look absent; resolving a doc's relative links from the repository root instead of from the file's own directory makes every link look dead. Both produced a list of confident findings and both were entirely wrong. A third instance is reading a UTC timestamp as local time and concluding that a workflow had not run since the previous evening, when it had run twenty minutes earlier — same shape, wrong context, confident conclusion. Before acting on a sweep, check that the thing being searched is the thing the claim is about, including the clock it is written in. -- `src/go-limits.ts` — the allowance lookup, the label normalization and the money formatting. `allowanceFor` is the tested one and `attachAllowance` in `usage.ts` is what the meter actually calls, and they are the same lookup written twice: the second inlines `GO_MODEL_LIMITS[model] ?? GO_MODEL_LIMITS[model.toLowerCase()]` where the first normalizes. They agree today, which is why this is a duplication and not a bug — and the tested one is the one nothing calls, so the next change to either will land on the untested side. +- `src/go-limits.ts` — the allowance lookup, the label normalization and the money formatting. `allowanceFor` is the one lookup: `attachAllowance` in `usage.ts` calls it rather than inlining a second copy, which it used to. - `src/go-limits-data.ts` — the Go plan allowances, per model and per tier, **generated** from the vendor's own page by `scripts/generate-go-limits.ts`; never hand-edit it, and regenerate with `pnpm run limits:shim -- --write`. The scheduled freshness job is what notices when the page moves, which it did: the docs went under `/v2/docs/console/` and serve HTML now, so the generator reads a table off that page rather than the markdown it used to. - `src/responses-routes.ts` · `src/responses-provider.ts` — the protocol table and the in-process mount of the host's `llm-pi-ai`. -- `src/adapter-replacement.ts` — taking a provider route over from the adapter that already serves it: `llm.registerAdapter` refuses a route the host owns (`DUPLICATE_ADAPTER`), so the current entry is released first, our adapter is registered through the ordinary API, and the original is put back on disposal. The step towards serving a mixed-protocol route as the same provider the user configured, instead of mounting hidden ones. +- `src/adapter-replacement.ts` — taking a provider route over from the adapter that already serves it: `llm.registerAdapter` refuses a route the host owns (`DUPLICATE_ADAPTER`), so the current entry is released first, our adapter is registered through the ordinary API, and the original is put back on disposal. The step towards serving a mixed-protocol route as the same provider the user configured, instead of mounting hidden ones. **Not wired yet**: nothing in `src/` imports it, only its own test, so read it as the next step rather than as live behaviour. - `src/purge-service.ts` · `src/purge-contract.ts` — the card's "purge deprecated models" action, and the shape it answers with. It **rewrites the profile**, deleting the declared model entries the live catalog has retired, through `configEditor` (the same service the settings UI writes with). That is deliberate: a read-time filter would leave the declaration in place, and then the only way to explain a missing row is to find the filter. One source of truth, and it is the configuration. - `src/session-cost.ts` · `src/cordis-context.ts` · `src/debug.ts` — per-turn accounting, the typed host interfaces, and stream debug logging. - `src/pi-provider.ts` — the per-protocol base URLs, the pi model object, and the gateway headers. Every value here is measured; see `docs/redirect-planes.md`. @@ -95,21 +95,16 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f - `src/opencode-paths.ts` — where an OpenCode installation keeps its files, ported from `~/dev/opencode`'s `global-roots.ts` (the directory) and `database-path.ts` (the filename). It takes `home`, `env` and `channel` as parameters and reads neither the environment nor the filesystem, which is what makes the Windows and Linux layouts testable from here. Two facts it encodes that are easy to get wrong: the directory is XDG on EVERY platform — macOS and Windows use `~/.local/share` too, and `os.homedir()` is the only platform-dependent call — and the FILENAME is not constant, being `$OPENCODE_DB`, else `opencode.db` on a stable channel, else `opencode-.db`. The database sits under `data` while the config sits under `config`, so one root cannot serve both. It is the foundation of the opt-in reader: `go-discovery.ts`'s `resolveOpencodeStoreKey` is what imports it, and nothing else does. - `src/opencode-store.ts` — reading the credentials an OpenCode installation already holds, through `node:sqlite`. Two rules shape it: nothing throws (a missing file, a foreign schema and `:memory:` all answer `undefined`), and a value never leaves the return value — no log line, no error message, no client bundle. `node:sqlite` is host-only, which is what keeps the module on the host side of the bundle rule. It is reached only through `resolveOpencodeStoreKey`, which is the last step of every key-source policy and is off unless `readOpencodeStore` is on. Its second reader, `inspectOpencodeStore`, answers the _other_ question — "is there a credential here, and whose is it?" — with a sanitized summary (email, org, kind, expiry) and never the token, which is what lets the settings card show the evidence beside the permission. - `src/opencode-store-contract.ts` · `src/opencode-store-service.ts` — the inspection seam: the sanitized summary type both sides share, and the host service that produces it. The contract is pure data (no `node:`, no context), so the client bundle may import it; the service is the half that touches `node:sqlite` and the filesystem, and it rewrites the store path to `~`-relative before answering — a home directory is a fact about the machine, not about the plugin. `inspect` is declared through the `Object.defineProperty` marker rather than `@Remote`, for the reason `usage.ts` documents. -- `src/console-auth.ts` — the Console device flow and the two reads a login needs: `/api/user` and `/api/orgs`. -- `src/token-store.ts` — a login as one `grant` record: the token pair and its owner, written atomically. -- `src/login-flow.ts` — the flow that keeps what it learned. A login that cannot read its owner FAILS rather than storing a token without its org. It also exports two conveniences nothing calls: `orgForLogin`, which is `login?.orgId`, and `summarizeLogin`, which is the owner and the tokens as one value. They are not missing behaviour the way `login-session.ts` was — the seam's `state()` deliberately carries no tokens, so `summarizeLogin` is for a host-side caller that does not exist yet, and `orgForLogin` is a one-line accessor. Read them as API surface rather than as wiring: safe to delete, and nothing is silently absent while they are here. -- `src/login-credential.ts` — one reader for a request's login credential, shared by the fetch patch and the Google bridge so neither knows how a grant is stored. It answers a token **with its org**, because the two travel together: a request has to carry the org, and a token with the wrong one is worse than no token. Sessions are cached per plane so concurrent callers share a refresh, but every operation re-reads the grant, so one written after a session was built is still seen. The order is the deployment's own — the DSH grant on the requested plane, then the same account through the other plane, then the installation's account when the deployment opted in — and an unknown plane consults nothing. A revoked grant of an existing owner is a `MISSING_CREDENTIAL` failure rather than a fallback, because absence permits one and a revocation does not. -- `src/login-contract.ts` · `src/login-service.ts` — the three calls a settings card makes (`begin`, `poll`, `state`), their codecs, and the host half that answers them. **A credential never crosses this seam**: the state carries an email and an org, and the parser is a whitelist. -- `src/login-session.ts` — the session for one plane: it joins the store and the client and refreshes the token BEFORE handing it out, so `token()` answers one good for the next call and a caller never handles an expired one. **`loginTokenReader` goes through it**, which is the point — reading the store directly, which the reader used to do, hands out whatever is on disk until it expires and then fails. Worth knowing how it was found: it was referenced by nothing but its own test for a while, and the first reading of that was 'dead code'. It was the half that was missing. -- A timing dependency both remotes share, worth knowing before debugging a row that will not appear: the mount is an async `$mount` started in an effect, and the card reads `ctx.remote.opencodeLogin` / `.opencodeGoUsage` SYNCHRONOUSLY when its injector builds the props. If the injector runs before the mount resolves, the service is not there yet and the surface renders nothing. The meter won that race in the deployment this was written for — its card rendered with `readUsage` present, which is only possible if the injector saw the mounted remote — and the sign-in row did NOT, because its remote was never mounted at all. So the race is real but was not the cause here; the cause was the missing mount, one entry up. Both now report when the lookup comes back empty — `no opencodeLogin service on the remote` with the keys the remote does expose — so the symptom is a line in the console rather than a blank. -- `src/org-cache.ts` — the org a plane's requests carry, in one small module both sides import. `patchFetch` asks for it synchronously and cannot go to the store itself, so the login service writes it when a login completes: a value read once at startup would miss a grant that appeared minutes later, which is exactly what it did. +- A timing dependency every remote shares, worth knowing before debugging a row that will not appear: the mount is an async `$mount` started in an effect, and the card reads `ctx.remote.` SYNCHRONOUSLY when its injector builds the props. If the injector runs before the mount resolves, the service is not there yet and the surface renders nothing. More often the cause is a namespace with no client descriptor at all — the purge row and the store-inspection row each shipped that way — which `test/remote-contract.test.ts` now catches per method. Each caller reports an empty lookup (`no opencodeStore service on the remote`), so the symptom is a console line rather than a blank. +- **Sign-in is not in this build.** An earlier line of work carried a Console device-flow login (`console-auth`, `token-store`, `login-*`, `org-cache`, `settings-login`); main carries the API-key path only, and the login is v2 work. The settings copy still holds its strings, and `credentialOrg` is the one remnant with behaviour: a configured org is sent as `x-opencode-org-id`. -Web client bundle (`lib/client.js`): `src/settings-page.tsx` (entry, wiring only), the `settings-*` card and its controls, the sign-in row — `src/settings-login.tsx` — and the meter — `src/usage-pill.tsx` (state), `src/usage-panel.tsx` (pure views), `src/usage-ui.ts` (logic and stylesheet). +Web client bundle (`lib/client.js`): `src/settings-page.tsx` (entry, wiring only), the `settings-*` card and its controls — including the store-inspection row, `src/settings-store-inspect.tsx` — and the meter — `src/usage-pill.tsx` (state), `src/usage-panel.tsx` (pure views), `src/usage-ui.ts` (logic and stylesheet). `test/architecture.test.ts` walks this graph from the entry and fails on a node built-in, the cordis runtime, pi, or `process.env`. ## Commands ```sh -vp test run # unit suite +vp test run # unit suite (refuses to start below engines.node) +pnpm run test:shuffle # the unit suite in random order; CI runs it on Node 24 pnpm run test:mock # gateway mock suite: offline, no key, runs in CI vp test run --coverage # with the coverage ratchet (95/90/93/95) vp check # format, lint, types diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b8ab64d..4a54c144 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Prerequisites -- Node.js `>= 24`, `pnpm` (v12) +- Node.js `>= 24` (CI runs the version in `.node-version`, and the floor in a job of its own), `pnpm` (v12). The suite refuses to start below the floor and names both versions: below it, `node:sqlite`, `Intl.DurationFormat` and the ICU strings the tests pin differ, and the failures read like regressions. - A checkout of this repo; for live testing, a DeepSeek Harness profile (see README for the `link:` setup) ## Commands @@ -14,6 +14,8 @@ 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 @@ -59,6 +61,10 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Deterministic only.** No `Math.random()`, no fixed `sleep()` waits. Async file assertions poll with a deadline (`waitForFileContent`). - **No secret fixtures.** Session IDs are derived at runtime (`openCodeSessionIdFor`) or read from `OPENCODE_SESSION_ID`; never hardcode `ses_…` or API keys. `lib/` and `*.log` stay gitignored. - **Restore globals.** Tests that touch `globalThis.fetch` or `process.env` must restore them in `afterEach` (`vi.unstubAllGlobals()`, `delete process.env.…`). +- **Offline by construction.** `test/support/offline.ts` refuses every non-loopback request in the unit and mock suites. The catalog's background revalidation used to reach `models.opencode.ai` dozens of times per run, so a networked runner's results could depend on the vendor's live catalog. A case that means to fetch stubs `fetch` itself. +- **No order dependence.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test. A new module-level store gets a reset next to its siblings. `pnpm run test:shuffle` is how to check, and CI runs it. +- **Host-only fixtures stay out of shared helpers.** `test/test-helpers.ts` is imported by the jsdom (client) tests, so anything that needs `node:sqlite` or the filesystem belongs in `test/support/`, as `opencode-store-fixture.ts` does. +- **A test for a fix fails without it.** Before committing a regression test, revert the fix locally and watch it fail. - **Meaningful coverage.** Every config field in the `SPECS` register (`src/settings-fields.ts`) needs both the on and off path where it has one; every passthrough claim needs a non-OpenCode URL test proving headers are untouched; helper functions exported from `usage-ui.ts` are asserted directly, not re-derived in the test. - **A component with hooks needs a real mount.** Calling `Component(props)` returns an element and runs none of its state, so a hook-bearing component can have twenty passing cases and still be untested — `usage-pill.tsx` sat at 9% exactly that way, with the poll loop, retry and dismissal all uncovered. `test/usage-pill-mount.test.tsx` is the answer: a `// @vitest-environment jsdom` pragma scoped to that one file, so the rest of the suite keeps the fast node environment and the zero-dependency element-tree style. diff --git a/scripts/check.ts b/scripts/check.ts index 72f9629c..f1af2485 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -538,8 +538,14 @@ if (releaseYml.includes("scripts/publish-scoped.ts")) { * list and twenty localized strings left with it. A ceiling that stayed at 140 * would have quietly stopped being a tripwire — 12 KB of slack is more than this * guard exists to tolerate. + * + * Raised 128 -> 132 KB when the store-inspection row's remote descriptor landed: + * the row had been calling a namespace the client never mounted, and the fix is + * the descriptor plus the whitelist parser its codec runs. Source, not a + * dependency — the client measured 132320 bytes against the old line, about + * 1.2 KB over, which is this guard's "raise it" case. */ -const CLIENT_BUDGET = 128 * 1024; +const CLIENT_BUDGET = 132 * 1024; const clientPath = join(ROOT, "lib/client.js"); if (existsSync(clientPath)) { const clientStat = statSync(clientPath); diff --git a/src/fetch-patch.ts b/src/fetch-patch.ts index e2710fbd..6ecd614b 100644 --- a/src/fetch-patch.ts +++ b/src/fetch-patch.ts @@ -329,7 +329,7 @@ export const patchFetch = ( ]; console.warn( `[dsh-opencode-patch] no credential resolved for ${plane}; sending the adapter's placeholder, which the gateway answers with 401. Consulted: ${sources.join("; ")}.`, - "set a key on the route or its env var, or sign in through the settings card" + "set a key on the route or its env var, or turn on readOpencodeStore to use the key an OpenCode installation already holds" ); } } diff --git a/test/fetch-patch.test.ts b/test/fetch-patch.test.ts index e0d9905c..1fe06d7f 100644 --- a/test/fetch-patch.test.ts +++ b/test/fetch-patch.test.ts @@ -92,6 +92,9 @@ describe("patchFetch", () => { "a key captured from a live request (policy auto): none" ); expect(line).toContain("not enabled"); + // The advice names a path this build has: there is no sign-in row here. + expect(line).not.toContain("sign in"); + expect(line).toContain("readOpencodeStore"); }); it("does NOT report a missing key on a plane that offers free models", async () => { From 52f175f1bc9093d7a791c471d75074711e55fed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:20:51 +0000 Subject: [PATCH 06/17] docs: add the improvement plan from the architecture review docs/improvement-plan.md groups the review's findings by cause and orders the fixes in nine phases after this one. Each phase lists its problems, its steps, the test that has to fail first, its acceptance criteria and any decision the owner needs to make. Findings are marked "measured" (reproduced) or "traced" (read in the code). Among the measured ones: the picker and dispatch derive reasoning levels differently for 9 of 95 catalog models. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- docs/README.md | 1 + docs/improvement-plan.md | 212 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 docs/improvement-plan.md diff --git a/docs/README.md b/docs/README.md index d8e2d6d4..244675fb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ One document per subject. Everything here is engineering material and written in | [`protocol-routing-and-merge.md`](./protocol-routing-and-merge.md) | the routing **implementation**: why routing exists, how the route is chosen, the catalog merge, and where the code lives | English | | [`engineering-notes.md`](./engineering-notes.md) | vendor findings, and the rules that came out of real failures | English | | [`credentials.md`](./credentials.md) | the credential chain: the deployment's own reference, both OAuth logins, refresh semantics, and the zero-configuration guarantee it must not break | English | +| [`improvement-plan.md`](./improvement-plan.md) | the architecture review: what is wrong, grouped by cause, and the phased order to fix it in | English | | [`quota-meter.md`](./quota-meter.md) | the composer meter, state by state: what it shows, what each row answers, and where the numbers come from | English | | [`quota-meter.zh-CN.md`](./quota-meter.zh-CN.md) | 同一份说明的中文版 | 中文 | diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md new file mode 100644 index 00000000..0197864e --- /dev/null +++ b/docs/improvement-plan.md @@ -0,0 +1,212 @@ +# Improvement plan + +An architecture review of the tree at 1.4.1, and the order in which to act on it. Each phase is one pull request with a Conventional Commit title, and each one lands behind tests that pin today's behaviour before anything moves. The first phase is already done; it is listed so the rest reads against what the suite now guarantees. + +**How the findings were checked.** Every finding below was traced to the code. The ones marked _measured_ were also reproduced: a probe script, or a test that fails before the fix. The rest are _traced_, and the phase that addresses each one begins by writing the failing test. A finding that turns out wrong at that step is dropped, not forced. + +## The shape of the problems + +Most findings share one of four causes, and the phases are grouped by cause rather than by file: + +| Cause | What it produces | Phases | +| :-- | :-- | :-- | +| **One decision written in several places** | copies that already disagree: credentials, protocol tables, reasoning levels, model metadata | 1, 2 | +| **Host patches without a lifecycle** | patches that stack or get stripped on reload, registrations that outlive their activation | 3, 4 | +| **Module state with process lifetime** | captured keys and catalog maps that survive a reload, and leaked between tests until phase 0 | 0, 1, 5 | +| **Modules that grew past one subject** | five files over 700 lines, each holding three to five jobs | 6 | + +## Phase 0 — the suite and the confirmed defects (this pull request) + +The suite now: + +- refuses to start below `engines.node`. Below Node 24 it used to fail for engine reasons that looked like regressions. +- runs offline. It used to send 54 requests per run to `models.opencode.ai`, so a networked runner could swap the catalog mid-test. +- resets module state after every test. Shuffled runs used to fail on seeds 42 and 1234. +- runs in random order on Node 24 in CI. Node 24 is the declared floor, which no job ran before. +- walks the client bundle's import graph and fails on anything host-only. +- checks every Remote method a host service declares against a client descriptor. + +The defects fixed, each with a test that fails without its fix: + +| Defect | Status | +| :-- | :-- | +| a rejected internal-route mount escaped as an unhandled rejection, which can exit the host | measured | +| three early exits leaked the Gemini bridge registrations, and the next reload failed with `DUPLICATE_ADAPTER` | measured | +| a mount that failed to start kept its fiber and every route it had registered | measured | +| model discovery answered from the first activation's settings for the life of the process | measured | +| Zen turns on two models were priced at the Go rate (`deepseek-v4-pro`: 0.66 instead of 1.74 per million input tokens) | measured | +| the listing and model-info patches rewrote providers this plugin does not claim | measured | +| a relay's Go `/models` was answered with the Zen catalog | measured | +| the free-model check never read a `Request` body, which is how DSH sends a turn | measured | +| the meter's session fallback picked the newest session, not the one that just ran | measured | +| a key's tier was read from its prefix; `oc_sk_` keys are issued for Go too | measured | +| the store-inspection row called a namespace the client never mounted | measured | +| the credential reader's legacy-row fallback was not per integration, so it hid the Zen key | measured | + +## Phase 1 — one credential resolver + +**Problem.** Six places decide "which key for which plane", with different rules: + +| Site | How it differs | +| :-- | :-- | +| `fetch-patch.ts` | ignores `KEY_SOURCE_ORDER` except for `configured`; hard-codes the env names; never reads the row's `apiKeyEnv` or literal key | +| `lifecycle.ts` `hasPlaneCredential` | reads `process.env` directly; asks for a captured key with no tier, so a Zen key satisfies Go | +| `go-discovery.ts` `resolveGoApiKey` | follows the policy order and filters placeholders — the reference behaviour | +| `go-discovery.ts` `resolveZenCreditInfo` | goes through `resolveRef`, which has no placeholder filter, so `OPENCODE_API_KEY=unused` counts as configured | +| `go-discovery.ts` `resolveRoutedKey` | exported, but no `src/` caller (_traced_) | +| `google-bridge.ts` `resolveGatewayKey` | no placeholder filter; throws on a miss; never reaches the installation store, because the caller always passes a reference (_traced_) | + +**Plan.** + +1. Write a **parity test first**: one table of inputs (policy × which sources hold a key × plane), asserted against every site above. Its failures are the real list of differences; record each as either "intended" or "bug". +2. Add `src/utils/credential-policy.ts`, which is pure: `chooseCredential({ plane, policy, sources })`, where `sources` holds values (request key, captured, declared env value, literal, store) rather than readers. It returns `{ key, source }` and applies the placeholder filter once. +3. Add `src/credentials.ts`, the effect half. It gathers the source values for a plane from the context, `process.env` and the capture store, then asks the utility. +4. Move each caller onto it, one per commit, with the parity test green after each. `hasPlaneCredential` becomes a thin wrapper that is still synchronous (it skips the async store). +5. Replace the module-level capture maps with a `CaptureStore` instance owned per root. Clear a key on a `401` answer, so a rotated key stops being sent. +6. `resolveRoutedKey` is a public export, so removing it is a breaking change. Mark it `@deprecated` in this phase and remove it in the next major. + +**Done when** the parity test has one row per policy × source combination and every site answers it identically, and `grep process.env src/fetch-patch.ts src/lifecycle.ts src/google-bridge.ts` is empty. + +## Phase 2 — one source per table + +**Problem.** These tables are written more than once, and the copies drift: + +- `PROTOCOL_FOR_SDK` (`responses-routes.ts`) and `GATEWAY_PROTOCOL_FOR_SDK` (`pi-provider.ts`) are identical. A comment says one has an extra entry; it does not. +- `PLANES` (`responses-routes.ts`) is a literal copy of `LEGACY_PLANE_API` (`pi-provider.ts`). A comment says they are the same table. +- There are four route-to-plane maps: `catalogPlaneForRoute`, `isPlaneRoute`, `responses-routes` `PLANES`, and `purge-service` `PLANES`. +- `ALL_THINKING_LEVELS` is defined twice. There are two `formatUsd` functions, with different contracts. +- **The picker and dispatch derive reasoning levels differently** (_measured_: 9 of 95 catalog models conflict). For a model with an on/off reasoning switch (`glm-5`, `minimax-m3`, `qwen3.7-plus`, …), the picker offers low, medium and high as separate levels, while dispatch sends all three as `on`. A comment in `models-discovery.ts` says the two never disagree. +- The model-metadata rule (overrides, context window, efforts, endpoint repair) is written four times: `toDiscovered` in `lifecycle.ts`, and `resolveModel`, `modelOf` and `decorateModelInfo` in `models-discovery.ts`. For the context window, the picker shows the catalog's value while dispatch keeps the adapter's. + +**Plan.** + +1. Add `src/utils/protocols.ts`: the SDK constants, the one protocol map, the plane table and the one `planeForRoute`. Derive `ROUTE_FOR_PROTOCOL` from it. `pi-provider` and `responses-routes` import from it, which also removes the import cycle that kept `PLANES` from being an alias. +2. Add `src/utils/model-metadata.ts`, pure: the override merge, the effective window, efforts and the reasoning map. The picker's levels and dispatch's map come from one function, so they cannot disagree. +3. Tests: + - a property test over the **whole bundled catalog**, asserting picker levels ⇔ dispatch map for every model. It fails today on the 9 models above. + - the same for the context window across `toDiscovered`, `decorateModelInfo` and `modelOf`. +4. **Decision needed:** what a toggle model's picker should offer. Either just "on/off", or the three levels with a note that they are equivalent. The first is closer to the vendor's own client. + +**Done when** each table has one definition (a grep for each name finds one `export const`), and the catalog-wide property test passes. + +## Phase 3 — a lifecycle for host patches + +**Problem.** + +- A live reload applies the new activation before it disposes the old one (`lifecycle.ts` documents this). +- Under that ordering, the identity-checked restores (`hideResponsesRoute`, `decorateModelDiscovery`, `decorateModelInfo`) never restore, so each reload leaves another wrapper in the chain. +- `decoratePlaneAdapters` restores unconditionally. The old activation's disposer strips the new one's wrappers, and the new disposer later puts back the old closures. +- `registeredRoutes` in `models-discovery.ts` is never cleared. +- The three root-scoped services each copy the same mount-once logic, with different logging (`console.warn` in two places, `ctx.logger` in one). `installUsageService` also captures the first activation's config for the life of the process. + +**Plan.** + +1. Build the test harness before any fix. Extract the faithful fake cordis from `test/responses-provider.test.ts` (`createHost`) into `test/support/fake-cordis.ts`. Add `test/reload.test.ts`, which runs: + + ``` + apply A → apply B → dispose A → assert B fully patched + → dispose B → assert every host method is the original + ``` + + for every patched method. It fails today. + +2. Add one `patchMethod(target, key, impl)` that returns a disposer which restores **only if its own wrapper is still the current value**, and splices itself out of a chain otherwise. Use it at all four patch sites. +3. Add one `mountOncePerRoot(ctx, name, Service, options)` for the three services, reporting through `ctx.logger` with a console fallback. Each service reads its settings from the latest activation, as model discovery now does. + +**Done when** the reload test passes for every host method this plugin replaces. + +## Phase 4 — the protocol mount and the Gemini bridge + +**Problem (traced).** + +- `google-bridge.ts` finds the host's `dsh-llm-pi-ai` from the plugin's own location, while `responses-provider.ts` finds it from `ctx.baseUrl`. Under a `link:` install, pi can come from this repository's dev tree while the adapter class comes from the host's copy. That is the version skew the module header says it prevents. +- `internalRoutes: false` does not stop the bridge. The gap report then warns that every Gemini model "speaks a protocol no route serves", because bridge routes are never added to `served`. +- The bridge registers through `llm.registerAdapter.bind(llm)`, after awaits, and does not re-check the routes it saw at the start. An in-flight mount from the old activation can collide with the new one. +- `adapter-replacement.ts` is not wired. It also calls `registerAdapter` detached, without `this`, and its restore writes the map directly, so no `llm/adapters-updated` event fires. + +**Plan.** + +1. One `resolveHostPackage(ctx)` from `ctx.baseUrl`; pi is resolved from that wrapper's own location. +2. Move the bridge loop into `google-bridge.ts` as `mountGoogleBridges`, as its own installer, with its own disposer and the existing containment. +3. Add bridge routes to `served`. **Decision needed:** whether `internalRoutes: false` turns the bridge off. The config docs suggest it should. +4. Re-read the registered routes immediately before each register. +5. `adapter-replacement.ts`: either wire it, with a test against a class-based registry that reads `this`, or delete it. A module that is "the next step" for several releases is a module to delete. + +## Phase 5 — catalog state + +**Problem.** + +- `parseModelsDevCatalog` clears module-level sets as a side effect, and it is a public export. +- `refreshCatalog` merges into the old maps and never removes anything, so a model retired after the shim was generated stays offered. +- The shim carries no deprecated ids, so `isDeprecatedModelOn` is always false before the first refresh. +- `lastRefreshSucceeded` is global. A background success can mask a failed forced refresh, which the purge then reports as `catalogRead: true`. +- The shim staleness check compares only the model arrays, so a move of the plane endpoints reads as "current". + +**Plan.** + +1. Make the parse pure: it returns `deprecated` and touches nothing. +2. Make refresh replace the maps rather than merge into them. +3. The refresh promise resolves `{ ok, deprecated }`, and the purge reads that rather than a global. +4. The generator emits the deprecated sets, and the staleness check compares the generator's whole output. +5. **Decision needed.** `toDiscovered` publishes the whole plane catalog under `opencode` and `opencode-go`, while `mergeDiscoveredModels` documents that discovery must never be widened past what the deployment declared. Both are active. Pick one and delete the other. +6. Purge: make one `edit` per entry that does both passes and skips entries with no change. Today every entry is rewritten, in two separate edits. + +## Phase 6 — modules named after one subject + +After phases 1–3 pin the behaviour, split these. Each split is a move with no behaviour change, verified by the unchanged suite. + +| Module | Lines | Becomes | +| :-- | --: | :-- | +| `models-discovery.ts` | 1191 | `discovery-merge.ts`, `listing-policy.ts` (with a `route-registry.ts`), `plane-adapters.ts`, `model-info.ts`, and `utils/model-metadata.ts` from phase 2 | +| `responses-provider.ts` | 1213 | the loader and host-package lookups move to `cordis-context.ts`, which removes the third copy of `loaderOfContext`; `reasoningEffortsFor`, `modelsForSdk` and the declared-model diffs move to `utils/`, taking the catalog as a parameter | +| `go-discovery.ts` | 724 | `declaredProviderModels` moves next to discovery, on a shared `providerRows(ctx)`; credential parts go to phase 1's module | +| `usage-ui.ts` | 974 | `usage-styles.ts` (the 445-line stylesheet), `usage-time.ts`, `usage-copy.ts`, `usage-failure.ts` | +| `fetch-patch.ts` | 500 | a staged pipeline: claim → credential → headers → body, each stage a pure function in `utils/` over values. The body is parsed once, not up to three times per request. | + +## Phase 7 — the remote seam + +**Problem.** + +- `"opencodeGoUsage"` is spelled as a literal 11 times across 5 files (_measured_). +- The `Object.defineProperty` remote marker block is copied three times. +- Result unwrapping is hand-written four times. `settings-purge.ts` still reports an `{ ok: false, error }` answer as "not a purge result" rather than the host's reason (_traced_); phase 0 fixed `settings-store.ts` only. +- `isGoUsage` in `settings-usage.tsx` is a third, looser validator next to `parseGoUsage`. + +**Plan.** Each contract exports `{ namespace, methods, descriptors }`. Add one host helper, `markRemote(prototype, contract)`, and one client helper, `callRemote(scope, contract, method, argument)`, which unwraps the envelope and parses with the contract's codec. `remote-contract.test.ts` then reads the contracts directly instead of grepping. + +## Phase 8 — test infrastructure + +1. Make `architecture.test.ts`'s `utils/` walk recursive. It reads only the top level today. +2. `client-bundle.test.ts` runs against whatever is in `lib/`, which can be stale. Build first, or fail with "lib/ is older than src/" rather than testing an old artifact. +3. Bring the 23 lint warnings in `src/` to zero, then make warnings gating for `src/`. +4. Add per-file coverage floors for `fetch-patch.ts`, `lifecycle.ts` and `models-discovery.ts`, the three modules every request crosses. +5. Optionally, run mutation testing (Stryker) on `src/utils/`. That folder is pure by construction, so it is cheap there, and it measures whether the assertions would notice a changed branch, which line coverage does not. +6. Session spend: the store is never evicted, and `history` is copied on every turn but never read. Cap the store with LRU eviction, and drop `history` or give it a reader. + +## Phase 9 — UI, against the house rules + +- `usage-pill.tsx` re-implements outside-pointer dismissal and the hover grace that `@deepseek-ai/dsh-client-ui-primitives` ships. The house rule is to start from the host's built-ins. +- `settings-choice-field.tsx` hard-codes a stroke colour, which the house rule ("opt into the theme; do not re-declare it") forbids. + +## Order, and why + +``` +0 ──► 1 ──► 3 ──► 6 + │ │ + ▼ ▼ + 2 4 ──► 5 + 7, 8, 9 at any point +``` + +- **1 before 6.** Splitting `go-discovery.ts` before there is one resolver would split six rules into more files. +- **3 before 4.** The bridge's reload collision is one case of the general patch lifecycle. +- **2 is independent** of 1 and can land in parallel. Its property test is the cheapest high-value test in the plan. +- **6 last of the structural work.** A move is safe only when the suite pins the behaviour being moved, and phases 1–3 add the missing pins. + +## Decisions the plan needs from the owner + +1. What a toggle-reasoning model's picker offers (phase 2). +2. Whether `internalRoutes: false` turns off the Gemini bridge (phase 4). +3. Whether to wire or delete `adapter-replacement.ts` (phase 4). +4. Which discovery policy holds: publish the plane catalog, or never widen past the declaration (phase 5). +5. When to remove the deprecated `resolveRoutedKey` export, which is breaking (phase 1). From 603d378e4642f9efe113a60caf812d401b101cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:54:59 +0000 Subject: [PATCH 07/17] docs(plan): the approved refactor plan, with the host's own contracts Replace docs/improvement-plan.md with the plan of record. It now holds: - the owner's decisions (D1-D11); - what the DeepSeek Harness host supports, with references into packages/llm, llm-pi-ai, session-controller, gateway and ui-settings-models; - the defects this audit found that are not yet fixed; - the target architecture and the phases, one PR each, with a status table that each PR updates. The decisions that change the code: - Settings offers the whole catalog, and the composer offers only the declared models. - The plugin never writes the user's profile and follows the user's configuration at dispatch. - The adapter patch exists to merge provider modes and nothing else. It is generalized to every mode, and its catalog fill is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- docs/README.md | 2 +- docs/improvement-plan.md | 440 ++++++++++++++++++++++----------------- 2 files changed, 250 insertions(+), 192 deletions(-) diff --git a/docs/README.md b/docs/README.md index 244675fb..0a0ae411 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ One document per subject. Everything here is engineering material and written in | [`protocol-routing-and-merge.md`](./protocol-routing-and-merge.md) | the routing **implementation**: why routing exists, how the route is chosen, the catalog merge, and where the code lives | English | | [`engineering-notes.md`](./engineering-notes.md) | vendor findings, and the rules that came out of real failures | English | | [`credentials.md`](./credentials.md) | the credential chain: the deployment's own reference, both OAuth logins, refresh semantics, and the zero-configuration guarantee it must not break | English | -| [`improvement-plan.md`](./improvement-plan.md) | the architecture review: what is wrong, grouped by cause, and the phased order to fix it in | English | +| [`improvement-plan.md`](./improvement-plan.md) | the plan of record: what the audit found, what the host supports, and the phased refactor, with status | English | | [`quota-meter.md`](./quota-meter.md) | the composer meter, state by state: what it shows, what each row answers, and where the numbers come from | English | | [`quota-meter.zh-CN.md`](./quota-meter.zh-CN.md) | 同一份说明的中文版 | 中文 | diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 0197864e..cb4b165b 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -1,212 +1,270 @@ # Improvement plan -An architecture review of the tree at 1.4.1, and the order in which to act on it. Each phase is one pull request with a Conventional Commit title, and each one lands behind tests that pin today's behaviour before anything moves. The first phase is already done; it is listed so the rest reads against what the suite now guarantees. +## Context -**How the findings were checked.** Every finding below was traced to the code. The ones marked _measured_ were also reproduced: a probe script, or a test that fails before the fix. The rest are _traced_, and the phase that addresses each one begins by writing the failing test. A finding that turns out wrong at that step is dropped, not forced. +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: -## The shape of the problems +- six audits: routing, catalog, credentials, config/UI, tests/CI/docs, and the stream path; +- an adversarial review of the plan itself; +- the host's own source (`deepseek-ai/deepseek-harness`): `packages/llm/llm`, `llm-pi-ai`, `api/session-controller`, `api/gateway`, and `client/ui-settings-models`. File references into the host are relative to that repository's `packages/`. -Most findings share one of four causes, and the phases are grouped by cause rather than by file: +**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. -| Cause | What it produces | Phases | -| :-- | :-- | :-- | -| **One decision written in several places** | copies that already disagree: credentials, protocol tables, reasoning levels, model metadata | 1, 2 | -| **Host patches without a lifecycle** | patches that stack or get stripped on reload, registrations that outlive their activation | 3, 4 | -| **Module state with process lifetime** | captured keys and catalog maps that survive a reload, and leaked between tests until phase 0 | 0, 1, 5 | -| **Modules that grew past one subject** | five files over 700 lines, each holding three to five jobs | 6 | - -## Phase 0 — the suite and the confirmed defects (this pull request) - -The suite now: +**Each phase is one pull request**, and it updates this document: its row in the status table 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. -- refuses to start below `engines.node`. Below Node 24 it used to fail for engine reasons that looked like regressions. -- runs offline. It used to send 54 requests per run to `models.opencode.ai`, so a networked runner could swap the catalog mid-test. -- resets module state after every test. Shuffled runs used to fail on seeds 42 and 1234. -- runs in random order on Node 24 in CI. Node 24 is the declared floor, which no job ran before. -- walks the client bundle's import graph and fails on anything host-only. -- checks every Remote method a host service declares against a client descriptor. +## Status -The defects fixed, each with a test that fails without its fix: - -| Defect | Status | +| Phase | Title | State | +| :-- | :-- | :-- | +| P0 | suite hardening, 11 defect fixes, this plan | in review | +| P1a | `fix:` shipped breakage | planned | +| P1b | `chore:` hygiene | planned | +| P2 | `feat!:` the 2.0.0 major | planned | +| P-T1 | `refactor:` fake host and patch lifecycle | planned | +| P3 | `feat:` Settings offers the catalog | planned | +| P4 | `feat:` follow the user's configuration; on/off on internal routes | planned | +| P-T2 | `test:` suite structure | planned | +| P-T3 | `ci:` CI and scripts | planned | +| P5 | `refactor:` installers | planned | +| P6 | `refactor:` one credential resolver | 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 | | :-- | :-- | -| a rejected internal-route mount escaped as an unhandled rejection, which can exit the host | measured | -| three early exits leaked the Gemini bridge registrations, and the next reload failed with `DUPLICATE_ADAPTER` | measured | -| a mount that failed to start kept its fiber and every route it had registered | measured | -| model discovery answered from the first activation's settings for the life of the process | measured | -| Zen turns on two models were priced at the Go rate (`deepseek-v4-pro`: 0.66 instead of 1.74 per million input tokens) | measured | -| the listing and model-info patches rewrote providers this plugin does not claim | measured | -| a relay's Go `/models` was answered with the Zen catalog | measured | -| the free-model check never read a `Request` body, which is how DSH sends a turn | measured | -| the meter's session fallback picked the newest session, not the one that just ran | measured | -| a key's tier was read from its prefix; `oc_sk_` keys are issued for Go too | measured | -| the store-inspection row called a namespace the client never mounted | measured | -| the credential reader's legacy-row fallback was not per integration, so it hid the Zen key | measured | - -## Phase 1 — one credential resolver - -**Problem.** Six places decide "which key for which plane", with different rules: - -| Site | How it differs | +| 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 break is documented**. | +| D2 | **Delete `internalRoutes`** | +| D3 | **Composer = declared models only. Settings = the whole catalog to pick from.** | +| D4 | Delete `resolveRoutedKey`. It was exported in dde9d6e and nothing in `src/` has ever called it. | +| D5 | Keep the per-protocol hidden routes, done the way the host supports | +| D6 | Shrink the public API in a major release (2.0.0) | +| D7 | Merge P0 first, then one PR per phase, each from `main` | +| D8 | **The plugin never writes the user's profile and never overrides the user's configuration at dispatch.** It complements what pi's provider list is missing and shows that in Settings when the user configures models. After that, dispatch follows the user's configuration. | +| D9 | Retire the deprecated `@viztor/dsh-opencode` wrapper in 2.0.0, after fixing its broken re-export in P1 | +| D10 | This document is the plan of record; each phase PR updates its status row | +| D11 | **The adapter patch exists to merge provider modes into the user's provider, nothing else.** It stays, and is generalized to every mode; anything in it that is not merging goes. | + +## 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. + - The redirect `llm.stream({ ...options, provider: hiddenRoute })` (`src/stream-hook.ts:251-266`) is the supported shape. It keeps `adapterStream`'s validation, projection, replay filtering and failure normalization (`index.ts:1064-1150`). +- **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`). + - **The adapter patch exists to merge provider modes, nothing else.** A declared model whose mode (Responses, Anthropic, Google) differs from the route's must resolve and dispatch as part of the user's own `opencode`/`opencode-go` provider. The plane route's adapter therefore answers `resolveModel`/`prepareCall`/`modelOf` for that model from the hidden route's adapter. Today that is done only for Go→Gemini (`models-discovery.ts:735-760`). + - Its catalog fill of context and levels for same-mode models (`:790-1010`) is not merging. Under D8 it goes: for a same-mode model, the picker and dispatch both follow the user's row, or pi's data where the user set nothing. +- **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`). + - One listing patch stays. Instance patches reach Remote calls, because the gateway resolves methods with `Reflect.get` at call time (`api/gateway/src/index.ts:841-843`). +- **Settings discovery is keyed by the pi-ai entry id** (`llm-pi-ai/src/index.ts:138,156`; `ProviderEditor.tsx:254`; `ModelListEditor.tsx:167,244`). The plugin's own `registerModelDiscovery` namespaces are never asked. + - The host's normalization runs inside the original `discoverModels` (`index.ts:618-631`), so the wrapper must dedupe and whitelist the fields itself. + - `LlmDiscoveredModel` carries only id, name, contextWindow, maxTokens and inputModalities, with no reasoning field. +- **D1 representation.** Effort ids must be pi levels (`adapter.ts:170-181`), and two vocabularies mean opposite things by `null`: + - pi `thinkingLevelMap`: `{ minimal: null, low: null, medium: null, high: "high" }`, with **no `off` key**. `off: null` hides Off and suppresses the explicit disable (`openai-completions.js:674,690,712`; `anthropic-messages.js:917`). + - profile `reasoningEfforts` dict: `{ off: null, high: "high" }`. Here null means "offered, send nothing" (`llm-pi-ai/src/catalog.ts:705-712`). + - Picker: `{ efforts: [{ id: "off", name: "Off" }, { id: "high", name: "On" }] }`. + - Offer Off only where the protocol can disable reasoning (`adapter.ts:184-196`). + - Verify that `"high"` is what each protocol sends for "on" before relying on it. Today `"on"` goes out verbatim (`openai-completions.js:718`, `anthropic-messages.js:733-735`). + +## Defects found in this audit, not yet fixed (all verified in code) + +| Defect | Where | | :-- | :-- | -| `fetch-patch.ts` | ignores `KEY_SOURCE_ORDER` except for `configured`; hard-codes the env names; never reads the row's `apiKeyEnv` or literal key | -| `lifecycle.ts` `hasPlaneCredential` | reads `process.env` directly; asks for a captured key with no tier, so a Zen key satisfies Go | -| `go-discovery.ts` `resolveGoApiKey` | follows the policy order and filters placeholders — the reference behaviour | -| `go-discovery.ts` `resolveZenCreditInfo` | goes through `resolveRef`, which has no placeholder filter, so `OPENCODE_API_KEY=unused` counts as configured | -| `go-discovery.ts` `resolveRoutedKey` | exported, but no `src/` caller (_traced_) | -| `google-bridge.ts` `resolveGatewayKey` | no placeholder filter; throws on a miss; never reaches the installation store, because the caller always passes a reference (_traced_) | - -**Plan.** - -1. Write a **parity test first**: one table of inputs (policy × which sources hold a key × plane), asserted against every site above. Its failures are the real list of differences; record each as either "intended" or "bug". -2. Add `src/utils/credential-policy.ts`, which is pure: `chooseCredential({ plane, policy, sources })`, where `sources` holds values (request key, captured, declared env value, literal, store) rather than readers. It returns `{ key, source }` and applies the placeholder filter once. -3. Add `src/credentials.ts`, the effect half. It gathers the source values for a plane from the context, `process.env` and the capture store, then asks the utility. -4. Move each caller onto it, one per commit, with the parity test green after each. `hasPlaneCredential` becomes a thin wrapper that is still synchronous (it skips the async store). -5. Replace the module-level capture maps with a `CaptureStore` instance owned per root. Clear a key on a `401` answer, so a rotated key stops being sent. -6. `resolveRoutedKey` is a public export, so removing it is a breaking change. Mark it `@deprecated` in this phase and remove it in the next major. - -**Done when** the parity test has one row per policy × source combination and every site answers it identically, and `grep process.env src/fetch-patch.ts src/lifecycle.ts src/google-bridge.ts` is empty. - -## Phase 2 — one source per table - -**Problem.** These tables are written more than once, and the copies drift: - -- `PROTOCOL_FOR_SDK` (`responses-routes.ts`) and `GATEWAY_PROTOCOL_FOR_SDK` (`pi-provider.ts`) are identical. A comment says one has an extra entry; it does not. -- `PLANES` (`responses-routes.ts`) is a literal copy of `LEGACY_PLANE_API` (`pi-provider.ts`). A comment says they are the same table. -- There are four route-to-plane maps: `catalogPlaneForRoute`, `isPlaneRoute`, `responses-routes` `PLANES`, and `purge-service` `PLANES`. -- `ALL_THINKING_LEVELS` is defined twice. There are two `formatUsd` functions, with different contracts. -- **The picker and dispatch derive reasoning levels differently** (_measured_: 9 of 95 catalog models conflict). For a model with an on/off reasoning switch (`glm-5`, `minimax-m3`, `qwen3.7-plus`, …), the picker offers low, medium and high as separate levels, while dispatch sends all three as `on`. A comment in `models-discovery.ts` says the two never disagree. -- The model-metadata rule (overrides, context window, efforts, endpoint repair) is written four times: `toDiscovered` in `lifecycle.ts`, and `resolveModel`, `modelOf` and `decorateModelInfo` in `models-discovery.ts`. For the context window, the picker shows the catalog's value while dispatch keeps the adapter's. - -**Plan.** - -1. Add `src/utils/protocols.ts`: the SDK constants, the one protocol map, the plane table and the one `planeForRoute`. Derive `ROUTE_FOR_PROTOCOL` from it. `pi-provider` and `responses-routes` import from it, which also removes the import cycle that kept `PLANES` from being an alias. -2. Add `src/utils/model-metadata.ts`, pure: the override merge, the effective window, efforts and the reasoning map. The picker's levels and dispatch's map come from one function, so they cannot disagree. -3. Tests: - - a property test over the **whole bundled catalog**, asserting picker levels ⇔ dispatch map for every model. It fails today on the 9 models above. - - the same for the context window across `toDiscovered`, `decorateModelInfo` and `modelOf`. -4. **Decision needed:** what a toggle model's picker should offer. Either just "on/off", or the three levels with a note that they are equivalent. The first is closer to the vendor's own client. - -**Done when** each table has one definition (a grep for each name finds one `export const`), and the catalog-wide property test passes. - -## Phase 3 — a lifecycle for host patches - -**Problem.** - -- A live reload applies the new activation before it disposes the old one (`lifecycle.ts` documents this). -- Under that ordering, the identity-checked restores (`hideResponsesRoute`, `decorateModelDiscovery`, `decorateModelInfo`) never restore, so each reload leaves another wrapper in the chain. -- `decoratePlaneAdapters` restores unconditionally. The old activation's disposer strips the new one's wrappers, and the new disposer later puts back the old closures. -- `registeredRoutes` in `models-discovery.ts` is never cleared. -- The three root-scoped services each copy the same mount-once logic, with different logging (`console.warn` in two places, `ctx.logger` in one). `installUsageService` also captures the first activation's config for the life of the process. - -**Plan.** - -1. Build the test harness before any fix. Extract the faithful fake cordis from `test/responses-provider.test.ts` (`createHost`) into `test/support/fake-cordis.ts`. Add `test/reload.test.ts`, which runs: - - ``` - apply A → apply B → dispose A → assert B fully patched - → dispose B → assert every host method is the original - ``` - - for every patched method. It fails today. - -2. Add one `patchMethod(target, key, impl)` that returns a disposer which restores **only if its own wrapper is still the current value**, and splices itself out of a chain otherwise. Use it at all four patch sites. -3. Add one `mountOncePerRoot(ctx, name, Service, options)` for the three services, reporting through `ctx.logger` with a console fallback. Each service reads its settings from the latest activation, as model discovery now does. +| The deprecated wrapper re-exports a `default` that does not exist, an ESM link error. It is published every release. | `scripts/publish-scoped.ts:137`, `release.yml:124` | +| Settings shows pi's snapshot, not the catalog | `models-discovery.ts:142-198,532-539` | +| Mode merging is done only for Go→Gemini. For the other modes the picker shows catalog levels while dispatch validates against a different profile: toggle models show Minimal…Max with no Off, and Go `minimax-m3` + Xhigh/Max is refused on the internal Anthropic route. | `models-discovery.ts:607-616,735-760,1063-1203`; `pi-provider.ts:351-386`; `responses-provider.ts:111-150` | +| `isZenProvider("opencode-google")` is false | `utils/request-shape.ts:265-271` | +| `enrichModelsResponse` turns a 401/403/5xx into a 200 catalog. It must still fill the Go plane's 404. | `models-catalog.ts:800-804` | +| The warning token doesn't exist, so warnings render as caption text. The host's text token is `--dsw-alias-state-warn-label`. | `settings-card.tsx:159`, `settings-store-inspect.tsx:250` | +| The Settings spinner animates only if the meter is mounted | `settings-usage.tsx:248` → `usage-ui.ts:440` | +| The stream hook writes `debugFile` with `debug` off | `stream-hook.ts:327-340` vs `lifecycle.ts:361` | +| `settings-purge` reports `{ ok: false }` as "not a purge result" and casts instead of using the parser | `settings-purge.ts:47-55` | +| The stream hook's `prepared` handling is dead. The host never reads `options.prepared`. | `stream-hook.ts:44,200-203,258` | +| The partition test misses `debugFile` | `test/settings-page.test.tsx:1223` | +| `cordis.patch.yml`: phantom `showUsagePrice`, wrong `freeModelMarker: ''` text, incomplete field lists | `cordis.patch.yml:23,36,61` | +| `utils/retry.ts` uses `console` and global timers, against the purity gate | `utils/retry.ts:31,55` | +| `tool-fallback`: the Anthropic branch can't be reached, and the lookup is Go-first | `tool-fallback.ts:204,237-242` | +| Hard-coded catalog counts in tests, which the nightly refresh PR breaks by construction | `test/catalog.test.ts:94,200,208` | +| `.freebuff/project-id` is tracked | repo root | + +## Target architecture -**Done when** the reload test passes for every host method this plugin replaces. - -## Phase 4 — the protocol mount and the Gemini bridge - -**Problem (traced).** - -- `google-bridge.ts` finds the host's `dsh-llm-pi-ai` from the plugin's own location, while `responses-provider.ts` finds it from `ctx.baseUrl`. Under a `link:` install, pi can come from this repository's dev tree while the adapter class comes from the host's copy. That is the version skew the module header says it prevents. -- `internalRoutes: false` does not stop the bridge. The gap report then warns that every Gemini model "speaks a protocol no route serves", because bridge routes are never added to `served`. -- The bridge registers through `llm.registerAdapter.bind(llm)`, after awaits, and does not re-check the routes it saw at the start. An in-flight mount from the old activation can collide with the new one. -- `adapter-replacement.ts` is not wired. It also calls `registerAdapter` detached, without `this`, and its restore writes the map directly, so no `llm/adapters-updated` event fires. - -**Plan.** - -1. One `resolveHostPackage(ctx)` from `ctx.baseUrl`; pi is resolved from that wrapper's own location. -2. Move the bridge loop into `google-bridge.ts` as `mountGoogleBridges`, as its own installer, with its own disposer and the existing containment. -3. Add bridge routes to `served`. **Decision needed:** whether `internalRoutes: false` turns the bridge off. The config docs suggest it should. -4. Re-read the registered routes immediately before each register. -5. `adapter-replacement.ts`: either wire it, with a test against a class-based registry that reads `this`, or delete it. A module that is "the next step" for several releases is a module to delete. - -## Phase 5 — catalog state - -**Problem.** - -- `parseModelsDevCatalog` clears module-level sets as a side effect, and it is a public export. -- `refreshCatalog` merges into the old maps and never removes anything, so a model retired after the shim was generated stays offered. -- The shim carries no deprecated ids, so `isDeprecatedModelOn` is always false before the first refresh. -- `lastRefreshSucceeded` is global. A background success can mask a failed forced refresh, which the purge then reports as `catalogRead: true`. -- The shim staleness check compares only the model arrays, so a move of the plane endpoints reads as "current". - -**Plan.** - -1. Make the parse pure: it returns `deprecated` and touches nothing. -2. Make refresh replace the maps rather than merge into them. -3. The refresh promise resolves `{ ok, deprecated }`, and the purge reads that rather than a global. -4. The generator emits the deprecated sets, and the staleness check compares the generator's whole output. -5. **Decision needed.** `toDiscovered` publishes the whole plane catalog under `opencode` and `opencode-go`, while `mergeDiscoveredModels` documents that discovery must never be widened past what the deployment declared. Both are active. Pick one and delete the other. -6. Purge: make one `edit` per entry that does both passes and skips entries with no change. Today every entry is rewritten, in two separate edits. - -## Phase 6 — modules named after one subject - -After phases 1–3 pin the behaviour, split these. Each split is a move with no behaviour change, verified by the unchanged suite. - -| Module | Lines | Becomes | -| :-- | --: | :-- | -| `models-discovery.ts` | 1191 | `discovery-merge.ts`, `listing-policy.ts` (with a `route-registry.ts`), `plane-adapters.ts`, `model-info.ts`, and `utils/model-metadata.ts` from phase 2 | -| `responses-provider.ts` | 1213 | the loader and host-package lookups move to `cordis-context.ts`, which removes the third copy of `loaderOfContext`; `reasoningEffortsFor`, `modelsForSdk` and the declared-model diffs move to `utils/`, taking the catalog as a parameter | -| `go-discovery.ts` | 724 | `declaredProviderModels` moves next to discovery, on a shared `providerRows(ctx)`; credential parts go to phase 1's module | -| `usage-ui.ts` | 974 | `usage-styles.ts` (the 445-line stylesheet), `usage-time.ts`, `usage-copy.ts`, `usage-failure.ts` | -| `fetch-patch.ts` | 500 | a staged pipeline: claim → credential → headers → body, each stage a pure function in `utils/` over values. The body is parsed once, not up to three times per request. | - -## Phase 7 — the remote seam - -**Problem.** - -- `"opencodeGoUsage"` is spelled as a literal 11 times across 5 files (_measured_). -- The `Object.defineProperty` remote marker block is copied three times. -- Result unwrapping is hand-written four times. `settings-purge.ts` still reports an `{ ok: false, error }` answer as "not a purge result" rather than the host's reason (_traced_); phase 0 fixed `settings-store.ts` only. -- `isGoUsage` in `settings-usage.tsx` is a third, looser validator next to `parseGoUsage`. +``` +src/ + index.ts apply, Config, inject, name + config types (D6) + lifecycle.ts apply(): installers only + host/ + patch-method.ts patch a host method: restore only if still current; delete when the original + was inherited; read "unpatched" from the prototype; re-wrap on llm/adapters-updated + listing.ts the ONE listing patch: hide internal routes (every listProviders reader) + discovery.ts discoverModels: pi's answer + the catalog's missing models (D3, D8) + merge-modes.ts the adapter patch: a declared model in another mode answers from that + mode's hidden-route adapter, so the modes merge into one provider (D11) + internal-routes.ts the hidden routes + Gemini bridge: one installer, one disposer + 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, model-metadata (reasoning, both vocabularies), credential-policy, + request-shape, session-id, google-replay, 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 +``` -**Plan.** Each contract exports `{ namespace, methods, descriptors }`. Add one host helper, `markRemote(prototype, contract)`, and one client helper, `callRemote(scope, contract, method, argument)`, which unwraps the envelope and parses with the contract's codec. `remote-contract.test.ts` then reads the contracts directly instead of grepping. +`host/merge-modes.ts` is the adapter patch, and it **merges provider modes and nothing else**. For a declared model in another mode, the plane route's adapter (and the public `resolveModelInfo` the picker reads) answers from that mode's hidden-route adapter, so the picker and dispatch see one provider. Same-mode models are not touched (D8). + +Two private seams remain, documented and feature-detected, each with an upstream ask that deletes it: the listing patch, and the adapter map that `merge-modes` reaches. + +## Phases + +One PR each, from `main`, after P0 merges. The first commit of each phase is a test that fails on `main`. + +1. **P1a `fix:` shipped breakage.** + - Drop `export { default }` from the wrapper. The import test resolves `dsh-opencode-patch` from the dry-run scratch directory. + - `isZenProvider`. + - Status pass-through in `enrichModelsResponse`, keeping the Go 404 fill. + - The warning colour uses `--dsw-alias-state-warn-label`. + - Spinner keyframes in the settings styles. + - One debug gate. + - `settings-purge` uses `parsePurgeResult` and reads the envelope. +2. **P1b `chore:` hygiene.** + - Delete `adapter-replacement.ts` and its test, which nothing imports. Extend `docs/engineering-notes.md`'s plane-route override section (≈`:535-549`) with why the approach is unsupported. + - Delete the dead `prepared` code, and `.freebuff/`. + - The partition test walks `Config.dict`, and a manifest test checks `cordis.patch.yml` against `Config.dict`. + - `retry.ts` takes its timer and logger as parameters; the architecture test bans `console`/`setTimeout`/`setInterval` in `utils/` and walks recursively. + - `tool-fallback` dead branches and plane lookup. + - Replace `docs/improvement-plan.md` with this plan (D10). +3. **P2 `feat!:` the major.** + - `index.ts` → `apply`, `Config`, `inject`, `name` and the config types. + - All 30 importing test files (including e2e and mock) go through one `test/support/src.ts`, so later moves touch one file. + - Delete `resolveRoutedKey` (D4) and `internalRoutes` (D2; breaking for anyone who set it to false). Retarget P0's bridge-disposer test to the "every route declared" exit. + - Stop publishing `@viztor/dsh-opencode` (D9) and set its final deprecation message. + - Document the D1 effort break here, so every breaking change ships in one 2.0.0. The `BREAKING CHANGE:` footer lists all of them. +4. **P-T1 `refactor:` fake host and patch lifecycle.** + - `test/support/fake-cordis.ts` on the real `Context` (one import path), plus `fakeLlm()` and `fakeLogger()`. Retire the ~60 ad-hoc contexts (21 with no-op `effect`), `createMockContext` and `HarnessSimulator`. + - `test/reload.test.ts` (apply A → apply B → dispose A → B intact → dispose B → originals) fails today. + - `host/patch-method.ts` applied to every patch site, which makes it pass. It reads `listProviders` "unpatched" from the prototype, so the private `llm.adapters` reads in `stream-hook.ts:238-249` and `responses-provider.ts` go. The adapter patch keeps its read: merging modes needs the adapter map until upstream offers a seam. It re-wraps on `llm/adapters-updated`. +5. **P3 `feat:` Settings offers the catalog (D3, D8).** + - The `discoverModels` wrapper returns pi's answer plus the plane's **servable, non-deprecated** catalog models that are missing from it. It dedupes, and whitelists to `LlmDiscoveredModel`'s fields. + - The composer's `listModels` stays declared-only. + - Delete `installModelDiscovery`/`toDiscovered` and P0's two discovery-reload tests; the mount facade keeps answering the child's `registerModelDiscovery` (`responses-provider.ts:649`). + - Rewrite the "adds nothing" tests (`test/models-discovery.test.ts:207-210,307-319`) and the contradicting docs (`README.md:220`, `deep-dive.md:174-175`, `redirect-planes.md:102`). +6. **P4 `feat:` merge every provider mode; follow the user's configuration (D1, D5, D8).** + - **Keep the adapter patch and narrow it to its purpose: merging modes.** Generalize the Go→Gemini case (`models-discovery.ts:735-760`) to every mode on both planes. For a declared model whose catalog SDK maps to another protocol (`internalRouteFor`), the plane adapter's `resolveModel`, `prepareCall` and `modelOf`, and the public `resolveModelInfo`, answer from that mode's hidden-route adapter, re-labelled with the user's provider. So the picker's levels and dispatch's validation come from the same adapter. + - **Remove what is not merging:** the catalog fill of context, name, modalities and levels for same-mode models (`:790-1010`) and the matching overrides in `decorateModelInfo`. Same-mode models follow the user's row, or pi's data where the user set nothing. + - **Hidden-route profiles follow the user, complemented by the catalog.** Each declared model's profile on its hidden route takes the user's row settings first (name, context, max tokens, modalities, `reasoningEfforts`) and the catalog only where the user set nothing. + - **D1 on the hidden routes.** `utils/model-metadata.ts` `reasoningFor(spec)` emits both vocabularies, with the representation above. A toggle model offers Off/On there, unless the user's row says otherwise. Delete the duplicate `ALL_THINKING_LEVELS` and the three capitalisation copies. + - **The Go→Gemini lookup and the rest use one helper** that finds the target mode's adapter, re-wrapped on `llm/adapters-updated`. + - **Tests:** + - for every catalog model reachable through another mode on each plane, picker efforts = the levels dispatch accepts (both from the hidden-route adapter); + - a user-configured same-mode row's efforts and context reach both picker and dispatch unchanged; + - a user row for an other-mode model is carried onto its hidden route. + - **Verify** the "on" wire value per protocol with an e2e probe. +7. **P-T2 `test:` suite structure.** + - Move the 9 mock-booting unit files into `test/mock/`, de-duplicated against `request-shapes.mock.ts`, and the 3 offline e2e files into mock. + - Delete the self-testing harness and mock-gateway tests. + - Turn constants into invariants. + - Split the 7 big test files. + - One shared e2e environment helper; remove duplicated helpers, device-login mock routes and the hard-coded `ses_`. + - Coverage tests for `session-id` and `opencode-store-contract`, plus per-file floors for `lifecycle`/`stream-hook`/`fetch-patch`. +8. **P-T3 `ci:` CI and scripts.** + - One unit run (`test:coverage`), with build before tests that read `lib/`, and type-check `test/**/*.tsx`. + - One composite setup action; the catalog fork `if:` and the pnpm comments. + - Paid e2e runs only on schedule or dispatch. + - `check.ts` keeps lib freshness (once), the consumer install (loading the client half too) and the size tripwire; its pure file checks move to vitest. + - The generators share helpers and one staleness rule; `verify-*` scripts exit non-zero; `capture-ui.sh` has no machine paths; `no-local-paths` covers every published file plus `.github/` and `*.sh`. +9. **P5 `refactor:` installers.** `installRemoteService(ctx, Service, contract, options)` replaces the three mount-once copies. Each reads the latest activation's settings and reports through `ctx.logger`. Update `scripts/check.ts:320-334`'s probe in the same PR. +10. **P6 `refactor:` one credential resolver.** + - Parity test over the six deciders. + - `utils/credential-policy.ts` + `credentials.ts`; the placeholder filter applied once. + - The Gemini key gets the store fallback. + - `CaptureStore` per root, evicting a key on 401. + - No `process.env` in `fetch-patch`, `lifecycle` or `google-bridge`. +11. **P7 `refactor:` one source per table.** + - `utils/protocols.ts`: SDKs, one protocol map, one plane table, one `planeForRoute` (four copies today); `ROUTE_FOR_PROTOCOL` derived from it. + - One `formatUsd`. + - One `specFor(route, id)`. +12. **P8 `refactor:` settings table.** + - First, a snapshot test of the derived `Config`. + - Then `settings-table.ts` drives the schema (schemastery `union`/`const`/`loose`, checking that `loose` and `volatile` compose), the resolver, the types, the card, the copy and the manifest. + - Delete the 14 dead copy keys and `modelMarkers`; fix the zh `title`. +13. **P9 `refactor:` remote seam.** + - `defineRemote` contracts, `markRemote`, `callRemote` (one unwrap, parsed by the contract). + - Delete `isGoUsage` and the four hand-written unwraps; add the `opencodeGoUsage` constant. + - The parity test reads the contracts. +14. **P10a `refactor:` pure module moves, no behaviour change.** + - `models-discovery` → `host/*`. + - `responses-provider` → `host/internal-routes.ts`, with the loader lookups moved into `cordis-context.ts`. + - `go-discovery` → `credentials.ts`. + - `usage-ui` → css/time/state/copy; `parseFailure` → the usage contract. + - `fetch-patch` → `request/`. +15. **P10b `fix:` catalog state.** + - The parse is pure and returns `deprecated`; refresh replaces the maps. + - The refresh promise returns `{ ok, deprecated }` for the purge. + - The shim carries deprecated ids, and the staleness check compares the whole output. + - The purge makes one edit per entry and skips no-op entries. +16. **P11 `refactor:` UI on the primitives.** + - `SegmentedControl`, `Button` with `icon`, `useDismissOnOutsidePointer`, one `ActionRow`. + - Theme tokens only; inline styles moved to CSS. + - Re-measure the bundle budget. +17. **P12 `ci:`** make lint warnings gating once `src/` is at zero. + +**Upstream asks (deepseek-harness)**, each unlocking a deletion here: + +- a `registerAdapter(…, { listed: false })` option → delete the listing patch; +- a `resolveProfiles` export from `llm-pi-ai` → replace the `llmFacade` Proxy mount (`responses-provider.ts:610-722`) with `new PiAiAdapter(…)` + `registerAdapter`; +- a `reasoning` field on `LlmDiscoveredModel` → Settings can offer Off/On when the user adopts a model; +- a supported way for one provider to serve models in several modes (an adapter delegation seam, or one registration answering per model) → `merge-modes` stops reading the adapter map, and the hidden routes can go. + +## Order -## Phase 8 — test infrastructure +``` +P0 → P1a → P1b → P2 (2.0.0) → P-T1 → P3 → P4 → P-T2 → P-T3 → P5 → P6 → P7 → P8 → P9 → P10a → P10b → P11 → P12 +``` -1. Make `architecture.test.ts`'s `utils/` walk recursive. It reads only the top level today. -2. `client-bundle.test.ts` runs against whatever is in `lib/`, which can be stale. Build first, or fail with "lib/ is older than src/" rather than testing an old artifact. -3. Bring the 23 lint warnings in `src/` to zero, then make warnings gating for `src/`. -4. Add per-file coverage floors for `fetch-patch.ts`, `lifecycle.ts` and `models-discovery.ts`, the three modules every request crosses. -5. Optionally, run mutation testing (Stryker) on `src/utils/`. That folder is pure by construction, so it is cheap there, and it measures whether the assertions would notice a changed branch, which line coverage does not. -6. Session spend: the store is never evicted, and `history` is copied on every turn but never read. Cap the store with LRU eviction, and drop `history` or give it a reader. +- **P1a first:** shipped breakage. +- **P2 bundles every breaking change into one major:** D2, D4, D6, D9 and the D1 note. +- **P-T1 before P3/P4:** they rewrite the same patch sites, and need the reload test and `patchMethod`. +- **P3/P4 deliver the product rules.** P4 generalizes mode merging and removes the catalog fill. +- **P10a only after P5–P9:** a move is safe once tests pin what moves. -## Phase 9 — UI, against the house rules +## Verification (every phase) -- `usage-pill.tsx` re-implements outside-pointer dismissal and the hover grace that `@deepseek-ai/dsh-client-ui-primitives` ships. The house rule is to start from the host's built-ins. -- `settings-choice-field.tsx` hard-codes a stroke colour, which the house rule ("opt into the theme; do not re-declare it") forbids. +Run on Node 26 (`.node-version`): -## Order, and why +- `pnpm run check`: 0 errors, and no more warnings than before; +- `pnpm run test`, `test:shuffle` on 3+ seeds, and `test:mock`; +- `pnpm run build` + `node --experimental-strip-types scripts/check.ts`; +- `pnpm run test:coverage` at or above the ratchet. -``` -0 ──► 1 ──► 3 ──► 6 - │ │ - ▼ ▼ - 2 4 ──► 5 - 7, 8, 9 at any point -``` +Then on Node 24: `test:shuffle` + `test:mock`. -- **1 before 6.** Splitting `go-discovery.ts` before there is one resolver would split six rules into more files. -- **3 before 4.** The bridge's reload collision is one case of the general patch lifecycle. -- **2 is independent** of 1 and can land in parallel. Its property test is the cheapest high-value test in the plan. -- **6 last of the structural work.** A move is safe only when the suite pins the behaviour being moved, and phases 1–3 add the missing pins. +Before each commit, grep the diff and `lib/` for local paths. -## Decisions the plan needs from the owner +Also: -1. What a toggle-reasoning model's picker offers (phase 2). -2. Whether `internalRoutes: false` turns off the Gemini bridge (phase 4). -3. Whether to wire or delete `adapter-replacement.ts` (phase 4). -4. Which discovery policy holds: publish the plane catalog, or never widen past the declaration (phase 5). -5. When to remove the deprecated `resolveRoutedKey` export, which is breaking (phase 1). +- The phase's first test fails on `main`. +- For P3/P4, a host-level test against the cloned harness logic (`buildModelCatalog`, `discoverModels`, `resolveCallWithInfo`) over the fake host shows: + - the composer is declared-only; + - Settings is pi's list plus the missing catalog models; + - a user-configured row's efforts and context reach both picker and dispatch unchanged; + - an internal-route toggle model offers Off/On. +- P4's e2e probe confirms the "on" wire value per protocol against the live gateway (opt-in job). +- Push, open the PR with a Conventional title, and watch CI to green before starting the next phase. From 0a0dbb097603ddb5799ecdd930f247513654619f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 00:02:14 +0000 Subject: [PATCH 08/17] docs(plan): route every mode through the user's own provider Investigation of the host's source and of the pi catalog it pins (1.1.0) changed the routing design. - llm-pi-ai resolves `api` and `baseUrl` per model, and pi's installed `opencode` catalog already spans Anthropic, Google, Completions and Responses with per-protocol endpoints. One route serves every mode for every model pi knows. - The plugin fills only three gaps: models newer than pi's catalog (including all Gemini on Go), endpoint drift, and profile-level `api`/`baseURL` collapsing the modes. - The new design is one unregistered catalog adapter per plane, keyed by the user's route id, and the adapter patch (merge-modes) delegates only gap models to it. That deletes the hidden routes, the listing hide, the stream redirect, the child mount and the Gemini bridge registration. - A real-host test harness (P-T1) comes before any of it. - One upstream change (per-entry `api`/`baseURL` in llm-pi-ai, U1) would remove the routing layer entirely. It would also let Settings save a model pi doesn't know, which pi-ai's strict write validation rejects today. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- docs/improvement-plan.md | 198 +++++++++++++++++++++++++-------------- 1 file changed, 130 insertions(+), 68 deletions(-) diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index cb4b165b..8f3bbac9 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -6,11 +6,11 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which - six audits: routing, catalog, credentials, config/UI, tests/CI/docs, and the stream path; - an adversarial review of the plan itself; -- the host's own source (`deepseek-ai/deepseek-harness`): `packages/llm/llm`, `llm-pi-ai`, `api/session-controller`, `api/gateway`, and `client/ui-settings-models`. File references into the host are relative to that repository's `packages/`. +- the host's own source and data (`deepseek-ai/deepseek-harness`, and the pi 1.1.0 catalog it pins). File references into the host are relative to that repository's `packages/`. **Outcome:** a plugin that only uses host extension points the host supports; one source of truth per decision; a suite that pins behaviour before it moves; and the owner's product rules below. -**Each phase is one pull request**, and it updates this document: its row in the status table 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. +**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 @@ -20,9 +20,11 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which | P1a | `fix:` shipped breakage | planned | | P1b | `chore:` hygiene | planned | | P2 | `feat!:` the 2.0.0 major | planned | -| P-T1 | `refactor:` fake host and patch lifecycle | planned | -| P3 | `feat:` Settings offers the catalog | planned | -| P4 | `feat:` follow the user's configuration; on/off on internal routes | planned | +| P-T1 | `test:` a harness that is the real host | planned | +| P-R1 | `refactor:` one route, delegated gaps | planned | +| P-R2 | `fix:` endpoint drift and collapsed modes | planned | +| P3 | `feat:` Settings complements pi's list | planned | +| P4 | `feat:` follow the user's configuration; on/off for gap models | planned | | P-T2 | `test:` suite structure | planned | | P-T3 | `ci:` CI and scripts | planned | | P5 | `refactor:` installers | planned | @@ -34,6 +36,7 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which | P10b | `fix:` catalog state | planned | | P11 | `refactor:` UI on the primitives | planned | | P12 | `ci:` lint warnings gating | planned | +| U1 | upstream: per-entry `api`/`baseURL` in llm-pi-ai | to propose | ## Owner decisions @@ -43,13 +46,13 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which | D2 | **Delete `internalRoutes`** | | D3 | **Composer = declared models only. Settings = the whole catalog to pick from.** | | D4 | Delete `resolveRoutedKey`. It was exported in dde9d6e and nothing in `src/` has ever called it. | -| D5 | Keep the per-protocol hidden routes, done the way the host supports | +| D5 | **Serve every mode on the user's own route, with no hidden routes.** The host already does this per model for every model pi's catalog knows. The plugin fills only the gaps (see "Routing"), through one unregistered adapter per plane that the user's route delegates gap models to. | | D6 | Shrink the public API in a major release (2.0.0) | | D7 | Merge P0 first, then one PR per phase, each from `main` | | D8 | **The plugin never writes the user's profile and never overrides the user's configuration at dispatch.** It complements what pi's provider list is missing and shows that in Settings when the user configures models. After that, dispatch follows the user's configuration. | | D9 | Retire the deprecated `@viztor/dsh-opencode` wrapper in 2.0.0, after fixing its broken re-export in P1 | | D10 | This document is the plan of record; each phase PR updates its status row | -| D11 | **The adapter patch exists to merge provider modes into the user's provider, nothing else.** It stays, and is generalized to every mode; anything in it that is not merging goes. | +| D11 | **The adapter patch exists to merge provider modes into the user's provider, nothing else.** It stays (`merge-modes`) and covers every gap model; anything in it that is not merging goes. | ## What the host supports (read from source) @@ -58,10 +61,10 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which - `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. - - The redirect `llm.stream({ ...options, provider: hiddenRoute })` (`src/stream-hook.ts:251-266`) is the supported shape. It keeps `adapterStream`'s validation, projection, replay filtering and failure normalization (`index.ts:1064-1150`). + - Today's redirect, `llm.stream({ ...options, provider: hiddenRoute })` (`src/stream-hook.ts:251-266`), is supported but needs a registered hidden route. Delegating at the adapter (F6, F7) 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`). - - **The adapter patch exists to merge provider modes, nothing else.** A declared model whose mode (Responses, Anthropic, Google) differs from the route's must resolve and dispatch as part of the user's own `opencode`/`opencode-go` provider. The plane route's adapter therefore answers `resolveModel`/`prepareCall`/`modelOf` for that model from the hidden route's adapter. Today that is done only for Go→Gemini (`models-discovery.ts:735-760`). + - **The adapter patch exists to merge provider modes, nothing else (D11).** A declared model that pi cannot serve on the route (a gap, see "Routing") must resolve and dispatch as part of the user's own `opencode`/`opencode-go` provider. The route's adapter therefore hands `listModels`/`resolveModel`/`prepareCall` for that model to the plugin's catalog adapter. Today it does this 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`) is not merging. Under D8 it goes: for a same-mode model, the picker and dispatch both follow the user's row, or pi's data where the user set nothing. - **Hiding.** - There is no visibility flag (`LlmProviderInfo = {id, name}`). @@ -72,7 +75,7 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which - 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`). - - One listing patch stays. Instance patches reach Remote calls, because the gateway resolves methods with `Reflect.get` at call time (`api/gateway/src/index.ts:841-843`). + - 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. @@ -104,6 +107,56 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which | Hard-coded catalog counts in tests, which the nightly refresh PR breaks by construction | `test/catalog.test.ts:94,200,208` | | `.freebuff/project-id` is tracked | repo root | +## Routing: the problem, the measured facts, the design + +**The problem, in the owner's words.** A provider defines one backend. Supporting new models meant un-registering the original provider and registering a new one, which the host does not support; so the plugin mounted extra routes per protocol and merged their models back into the user's provider. + +**Facts from the host's own code and data.** F1–F5 are measured: the data files were counted and the resolver read. F6–F7 are read from the code. P-T1's real-host harness proves them end to end before P-R1 relies on them, 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`; the same in the 0.2.1-alpha.5 build this repo uses (`lib/index.js:689`) | +| F2 | **pi's installed `opencode` catalog spans four protocols, each with its own endpoint.** In pi 1.1.0, which the host pins and patches: Zen has 18 anthropic-messages (`/zen`) and 7 google, 28 completions and 30 responses (`/zen/v1`). Go has 5 anthropic (`/zen/go`) and 19 completions and 6 responses (`/zen/go/v1`), and **no google**. | `@earendil-works/pi-ai@1.1.0` `dist/providers/data/opencode*.json`; harness `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. | `catalog.ts:600-640,681-691,917-921` | +| F4 | **Such a model cannot be saved, listed or dispatched on the user's 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`. | `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. | `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 | **An adapter that is never registered can share the route's id.** The host checks duplicates only in its registry, so a plugin-owned `PiAiAdapter` whose profile is keyed `opencode`/`opencode-go` can answer for that route when the route's own adapter delegates to it. The Gemini bridge already builds such an adapter by hand. | `llm/src/index.ts:399-460`; `src/google-bridge.ts:541-600` | + +**So the plugin has exactly three gaps to fill, and nothing else:** + +- **G1:** models newer than pi's catalog on a plane, which includes every Gemini model on Go (F2); +- **G2:** endpoint drift, where pi's stored bases are retired or are the wrong family for the deployment's `endpoint`; +- **G3:** a profile-level `api`/`baseURL` that collapses the modes (F5). + +Stale metadata for models pi knows is **not** a gap: under D8 the user's row, or pi, decides. + +**The design ("one route, delegated gaps"):** + +- **`host/catalog-adapter.ts`.** One `PiAiAdapter` per plane, built from the host's own llm-pi-ai class, as the bridge does today. Its profile is keyed by the user's route id and holds only the gap models, as pi `Model`s with their own `api` and `baseUrl` (from the catalog, family-aware). The user's row values come first (D8). It is **never registered**. +- **`host/merge-modes.ts`, the adapter patch (D11).** On the user's plane route adapter only: + - `listModels` appends the declared gap ids; + - `resolveModel` and `prepareCall` hand a gap model, with the same arguments, to the catalog adapter; + - everything else is the user's route answering as it always does. + - Picker, validation and dispatch therefore read one answer, through the host's own path (F6). +- **Deleted:** + - the llm-pi-ai child mount and its `llmFacade` Proxy; + - every hidden route and its tables (`responses-routes.ts`, the hidden ids in `DEFAULT_PROVIDERS`); + - the listing hide (`listProviders`, `listConfigurableProviders`, `listModels`); + - the stream redirect and `isAvailable`; + - the Gemini bridge registration, which becomes ordinary G1 models; + - the Google history and replay translation, if the delegated path keeps replay on one provider label (verify by test); + - `internalRoutes` (D2). +- **What remains private:** one seam, the registry's adapter map, to wrap the user's route adapter, plus the hand-built profile shape. Both are pinned by contract tests against the real `@deepseek-ai/dsh-llm` and `@deepseek-ai/dsh-llm-pi-ai` packages (already in `node_modules`), so a host upgrade that changes either fails CI rather than a user's turn. + +**The limit no plugin can remove (F4).** A user cannot **save** a gap model through Settings, because pi-ai rejects the write. Today such models work only when declared in the deployment's config file, which is read in deferred mode. D3's "whole catalog in Settings" is therefore fully deliverable only with upstream change U1. + +**Upstream changes to deepseek-harness, in order of value:** + +- **U1:** a `models` entry, and `modelOverrides`, accept their own `api` and `baseURL`. In `resolveEntry`, `entry.api ?? request.api ?? base?.api ?? routeApi`, and the same for `baseURL`. `LlmDiscoveredModel` gains `api` (and `reasoning`), so a Settings adoption writes it. **With U1, pi serves every declared model natively, and `catalog-adapter`, `merge-modes` and the registry seam are deleted.** The plugin's routing layer becomes zero code, and what remains is the Settings complement. +- **U2:** a `resolveProfiles` export, so the catalog adapter's profile is built by llm-pi-ai rather than by hand (only needed until U1). +- Withdrawn: `registerAdapter(…, { listed: false })`. Without hidden routes nothing needs hiding. + ## Target architecture ``` @@ -113,11 +166,11 @@ src/ host/ patch-method.ts patch a host method: restore only if still current; delete when the original was inherited; read "unpatched" from the prototype; re-wrap on llm/adapters-updated - listing.ts the ONE listing patch: hide internal routes (every listProviders reader) discovery.ts discoverModels: pi's answer + the catalog's missing models (D3, D8) - merge-modes.ts the adapter patch: a declared model in another mode answers from that - mode's hidden-route adapter, so the modes merge into one provider (D11) - internal-routes.ts the hidden routes + Gemini bridge: one installer, one disposer + catalog-adapter.ts one unregistered PiAiAdapter per plane, keyed by the user's route id, + holding only the gap models (G1-G3) with their own api and baseUrl + merge-modes.ts the adapter patch on the user's route: gap models are listed, resolved + and prepared by the catalog adapter; nothing else is touched (D11) 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 @@ -128,9 +181,7 @@ src/ ui/ card, one ActionRow, meter, on the primitives ``` -`host/merge-modes.ts` is the adapter patch, and it **merges provider modes and nothing else**. For a declared model in another mode, the plane route's adapter (and the public `resolveModelInfo` the picker reads) answers from that mode's hidden-route adapter, so the picker and dispatch see one provider. Same-mode models are not touched (D8). - -Two private seams remain, documented and feature-detected, each with an upstream ask that deletes it: the listing patch, and the adapter map that `merge-modes` reaches. +There are no hidden routes, no listing hide, no stream redirect, no child mount, no Gemini bridge registration, and no `resolveModelInfo` override (D8). One private seam is left, the registry's adapter map that `merge-modes` reaches, and it is pinned by a contract test against the real host packages and deleted by U1. ## Phases @@ -154,95 +205,105 @@ One PR each, from `main`, after P0 merges. The first commit of each phase is a t 3. **P2 `feat!:` the major.** - `index.ts` → `apply`, `Config`, `inject`, `name` and the config types. - All 30 importing test files (including e2e and mock) go through one `test/support/src.ts`, so later moves touch one file. - - Delete `resolveRoutedKey` (D4) and `internalRoutes` (D2; breaking for anyone who set it to false). Retarget P0's bridge-disposer test to the "every route declared" exit. + - Delete `resolveRoutedKey` (D4) and `internalRoutes` (D2; breaking for anyone who set it to false). Retarget P0's bridge-disposer test to the "every route declared" exit (P-R1 later deletes the bridge, and the test with it). - Stop publishing `@viztor/dsh-opencode` (D9) and set its final deprecation message. - Document the D1 effort break here, so every breaking change ships in one 2.0.0. The `BREAKING CHANGE:` footer lists all of them. -4. **P-T1 `refactor:` fake host and patch lifecycle.** - - `test/support/fake-cordis.ts` on the real `Context` (one import path), plus `fakeLlm()` and `fakeLogger()`. Retire the ~60 ad-hoc contexts (21 with no-op `effect`), `createMockContext` and `HarnessSimulator`. - - `test/reload.test.ts` (apply A → apply B → dispose A → B intact → dispose B → originals) fails today. - - `host/patch-method.ts` applied to every patch site, which makes it pass. It reads `listProviders` "unpatched" from the prototype, so the private `llm.adapters` reads in `stream-hook.ts:238-249` and `responses-provider.ts` go. The adapter patch keeps its read: merging modes needs the adapter map until upstream offers a seam. It re-wraps on `llm/adapters-updated`. -5. **P3 `feat:` Settings offers the catalog (D3, D8).** - - The `discoverModels` wrapper returns pi's answer plus the plane's **servable, non-deprecated** catalog models that are missing from it. It dedupes, and whitelists to `LlmDiscoveredModel`'s fields. +4. **P-T1 `test:` a harness that is the real host.** + - Build `test/support/host.ts` on the **real** packages already in `node_modules`: a cordis `Context`, `@deepseek-ai/dsh-llm`'s `LlmRuntime` and `@deepseek-ai/dsh-llm-pi-ai`'s `apply`. It is pointed at the mock gateway (`test/mock/`) through `fetch`, with a profile builder for the deployment shapes that matter: no `models` list, a `models` list of known ids, gap ids in the config file, and a route-level `baseURL`/`api`. + - **Characterization tests** that record today's behaviour across those shapes: for each model, the endpoint and protocol it reaches, and what the composer (`buildModelCatalog`'s logic), validation (`resolveCallConfig`) and Settings (`discoverModels`) report. They are the safety net for P-R1, and the measurement behind G1–G3. + - **Contract tests** that pin the two private seams (the registry adapter map, and the hand-built llm-pi-ai profile shape) against the real packages, so an upgrade breaks CI, not a user. + - Retire the ~60 ad-hoc contexts (21 with no-op `effect`), `createMockContext` and `HarnessSimulator`. + - Add `host/patch-method.ts` (restore only if still current; delete when inherited; re-wrap on `llm/adapters-updated`), and `test/reload.test.ts` (apply A → apply B → dispose A → B intact → dispose B → originals) for the sites that remain. +5. **P-R1 `refactor:` one route, delegated gaps (D5, D11).** The core of the refactor; the steps are in "Routing". + - `host/catalog-adapter.ts`: one unregistered `PiAiAdapter` per plane, keyed by the user's route id, built from the gap models. + - `host/merge-modes.ts`: the user's route adapter lists, resolves and prepares gap models through it. + - Delete the hidden-route machinery: the child mount and its facade Proxy, `responses-routes.ts`, the listing hide, the stream redirect and `isAvailable`, the Gemini bridge registration, the hidden ids in `DEFAULT_PROVIDERS` and `cordis.patch.yml`, and the tests that pin them. + - The Google replay translators go too, if the characterization tests show replay stays on one provider label. + - **The P-T1 characterization tests must pass unchanged**, except the rows that recorded a defect, each named in the PR. + - **New tests:** + - a gap model on each plane, in each protocol, reaches its endpoint; + - a known model is answered by pi alone; + - a user's row for a gap model (name, context, max tokens, modalities, efforts) wins over the catalog. + - Expected size: a net deletion of well over a thousand lines. +6. **P-R2 `fix:` endpoint drift and collapsed modes (G2, G3).** + - Measure first, with P-T1's harness and the opt-in e2e: which of pi 1.1.0's bases (`/zen`, `/zen/v1`, `/zen/go`, `/zen/go/v1`) are retired or wrong for the deployment's `endpoint` family, and what a route-level `baseURL`/`api` does to known models. + - The delegation rule becomes "gap model, or a known model whose resolved endpoint or protocol is wrong". The catalog adapter serves it with the right base. + - If nothing is wrong, this phase only adds the tests that say so. +7. **P3 `feat:` Settings complements pi's list (D3, D8).** + - The `discoverModels` wrapper returns pi's answer plus the plane's **servable, non-deprecated** catalog models missing from it. It dedupes, and whitelists to `LlmDiscoveredModel`'s fields. + - **F4 limits this:** pi-ai rejects saving a gap model, so until U1 these rows are offered with that caveat in their name/description, or held back behind U1. This needs an owner decision. - The composer's `listModels` stays declared-only. - - Delete `installModelDiscovery`/`toDiscovered` and P0's two discovery-reload tests; the mount facade keeps answering the child's `registerModelDiscovery` (`responses-provider.ts:649`). - - Rewrite the "adds nothing" tests (`test/models-discovery.test.ts:207-210,307-319`) and the contradicting docs (`README.md:220`, `deep-dive.md:174-175`, `redirect-planes.md:102`). -6. **P4 `feat:` merge every provider mode; follow the user's configuration (D1, D5, D8).** - - **Keep the adapter patch and narrow it to its purpose: merging modes.** Generalize the Go→Gemini case (`models-discovery.ts:735-760`) to every mode on both planes. For a declared model whose catalog SDK maps to another protocol (`internalRouteFor`), the plane adapter's `resolveModel`, `prepareCall` and `modelOf`, and the public `resolveModelInfo`, answer from that mode's hidden-route adapter, re-labelled with the user's provider. So the picker's levels and dispatch's validation come from the same adapter. - - **Remove what is not merging:** the catalog fill of context, name, modalities and levels for same-mode models (`:790-1010`) and the matching overrides in `decorateModelInfo`. Same-mode models follow the user's row, or pi's data where the user set nothing. - - **Hidden-route profiles follow the user, complemented by the catalog.** Each declared model's profile on its hidden route takes the user's row settings first (name, context, max tokens, modalities, `reasoningEfforts`) and the catalog only where the user set nothing. - - **D1 on the hidden routes.** `utils/model-metadata.ts` `reasoningFor(spec)` emits both vocabularies, with the representation above. A toggle model offers Off/On there, unless the user's row says otherwise. Delete the duplicate `ALL_THINKING_LEVELS` and the three capitalisation copies. - - **The Go→Gemini lookup and the rest use one helper** that finds the target mode's adapter, re-wrapped on `llm/adapters-updated`. - - **Tests:** - - for every catalog model reachable through another mode on each plane, picker efforts = the levels dispatch accepts (both from the hidden-route adapter); - - a user-configured same-mode row's efforts and context reach both picker and dispatch unchanged; - - a user row for an other-mode model is carried onto its hidden route. - - **Verify** the "on" wire value per protocol with an e2e probe. -7. **P-T2 `test:` suite structure.** + - Delete `installModelDiscovery`/`toDiscovered` and P0's two discovery-reload tests. + - Rewrite the "adds nothing" tests (`test/models-discovery.test.ts:207-210,307-319`) and the contradicting docs (`README.md:220`, `deep-dive.md:14,174-175`, `redirect-planes.md:102`). `deep-dive.md:14`'s "one wire protocol per route" premise is false for this host (F1). +8. **P4 `feat:` follow the user's configuration; on/off for gap models (D1, D8).** + - Remove the catalog fill for models pi knows: the plane-route overrides in `decorateModelInfo`, and the same-mode fill inside the current adapter patch. The user's row, or pi, answers. + - For gap models, `utils/model-metadata.ts` `reasoningFor(spec)` builds the catalog adapter's levels in both vocabularies. A toggle model offers **Off/On**; old Minimal/Xhigh/Max selections break (documented in P2). + - Delete the duplicate `ALL_THINKING_LEVELS` and the three capitalisation copies. + - **Tests:** for every gap model on each plane, picker efforts = the levels dispatch accepts; for a known model, both equal pi's or the user's row; the "on" wire value is verified per protocol with an e2e probe. +9. **P-T2 `test:` suite structure.** - Move the 9 mock-booting unit files into `test/mock/`, de-duplicated against `request-shapes.mock.ts`, and the 3 offline e2e files into mock. - Delete the self-testing harness and mock-gateway tests. - Turn constants into invariants. - Split the 7 big test files. - One shared e2e environment helper; remove duplicated helpers, device-login mock routes and the hard-coded `ses_`. - Coverage tests for `session-id` and `opencode-store-contract`, plus per-file floors for `lifecycle`/`stream-hook`/`fetch-patch`. -8. **P-T3 `ci:` CI and scripts.** - - One unit run (`test:coverage`), with build before tests that read `lib/`, and type-check `test/**/*.tsx`. - - One composite setup action; the catalog fork `if:` and the pnpm comments. - - Paid e2e runs only on schedule or dispatch. - - `check.ts` keeps lib freshness (once), the consumer install (loading the client half too) and the size tripwire; its pure file checks move to vitest. - - The generators share helpers and one staleness rule; `verify-*` scripts exit non-zero; `capture-ui.sh` has no machine paths; `no-local-paths` covers every published file plus `.github/` and `*.sh`. -9. **P5 `refactor:` installers.** `installRemoteService(ctx, Service, contract, options)` replaces the three mount-once copies. Each reads the latest activation's settings and reports through `ctx.logger`. Update `scripts/check.ts:320-334`'s probe in the same PR. -10. **P6 `refactor:` one credential resolver.** +10. **P-T3 `ci:` CI and scripts.** + +- One unit run (`test:coverage`), with build before tests that read `lib/`, and type-check `test/**/*.tsx`. +- One composite setup action; the catalog fork `if:` and the pnpm comments. +- Paid e2e runs only on schedule or dispatch. +- `check.ts` keeps lib freshness (once), the consumer install (loading the client half too) and the size tripwire; its pure file checks move to vitest. +- The generators share helpers and one staleness rule; `verify-*` scripts exit non-zero; `capture-ui.sh` has no machine paths; `no-local-paths` covers every published file plus `.github/` and `*.sh`. + +11. **P5 `refactor:` installers.** `installRemoteService(ctx, Service, contract, options)` replaces the three mount-once copies. Each reads the latest activation's settings and reports through `ctx.logger`. Update `scripts/check.ts:320-334`'s probe in the same PR. +12. **P6 `refactor:` one credential resolver.** - Parity test over the six deciders. - `utils/credential-policy.ts` + `credentials.ts`; the placeholder filter applied once. - - The Gemini key gets the store fallback. + - The catalog adapter's key resolution is the same resolver, store fallback included. - `CaptureStore` per root, evicting a key on 401. - - No `process.env` in `fetch-patch`, `lifecycle` or `google-bridge`. -11. **P7 `refactor:` one source per table.** - - `utils/protocols.ts`: SDKs, one protocol map, one plane table, one `planeForRoute` (four copies today); `ROUTE_FOR_PROTOCOL` derived from it. + - No `process.env` in `fetch-patch`, `lifecycle` or the catalog adapter. +13. **P7 `refactor:` one source per table.** + - `utils/protocols.ts`: SDKs, one protocol map and one plane table (with the hidden routes gone after P-R1, the per-route tables go too), and one `planeForRoute` (four copies today). - One `formatUsd`. - One `specFor(route, id)`. -12. **P8 `refactor:` settings table.** +14. **P8 `refactor:` settings table.** - First, a snapshot test of the derived `Config`. - Then `settings-table.ts` drives the schema (schemastery `union`/`const`/`loose`, checking that `loose` and `volatile` compose), the resolver, the types, the card, the copy and the manifest. - Delete the 14 dead copy keys and `modelMarkers`; fix the zh `title`. -13. **P9 `refactor:` remote seam.** +15. **P9 `refactor:` remote seam.** - `defineRemote` contracts, `markRemote`, `callRemote` (one unwrap, parsed by the contract). - Delete `isGoUsage` and the four hand-written unwraps; add the `opencodeGoUsage` constant. - The parity test reads the contracts. -14. **P10a `refactor:` pure module moves, no behaviour change.** +16. **P10a `refactor:` pure module moves, no behaviour change.** - `models-discovery` → `host/*`. - - `responses-provider` → `host/internal-routes.ts`, with the loader lookups moved into `cordis-context.ts`. + - What is left of `responses-provider` and `google-bridge` after P-R1 → `host/catalog-adapter.ts`, with the loader lookups moved into `cordis-context.ts`. - `go-discovery` → `credentials.ts`. - `usage-ui` → css/time/state/copy; `parseFailure` → the usage contract. - `fetch-patch` → `request/`. -15. **P10b `fix:` catalog state.** +17. **P10b `fix:` catalog state.** - The parse is pure and returns `deprecated`; refresh replaces the maps. - The refresh promise returns `{ ok, deprecated }` for the purge. - The shim carries deprecated ids, and the staleness check compares the whole output. - The purge makes one edit per entry and skips no-op entries. -16. **P11 `refactor:` UI on the primitives.** +18. **P11 `refactor:` UI on the primitives.** - `SegmentedControl`, `Button` with `icon`, `useDismissOnOutsidePointer`, one `ActionRow`. - Theme tokens only; inline styles moved to CSS. - Re-measure the bundle budget. -17. **P12 `ci:`** make lint warnings gating once `src/` is at zero. - -**Upstream asks (deepseek-harness)**, each unlocking a deletion here: +19. **P12 `ci:`** make lint warnings gating once `src/` is at zero. -- a `registerAdapter(…, { listed: false })` option → delete the listing patch; -- a `resolveProfiles` export from `llm-pi-ai` → replace the `llmFacade` Proxy mount (`responses-provider.ts:610-722`) with `new PiAiAdapter(…)` + `registerAdapter`; -- a `reasoning` field on `LlmDiscoveredModel` → Settings can offer Off/On when the user adopts a model; -- a supported way for one provider to serve models in several modes (an adapter delegation seam, or one registration answering per model) → `merge-modes` stops reading the adapter map, and the hidden routes can go. +**Upstream asks (deepseek-harness):** U1 and U2 are described in "Routing". U1 deletes `catalog-adapter`, `merge-modes` and the last private seam. ## Order ``` -P0 → P1a → P1b → P2 (2.0.0) → P-T1 → P3 → P4 → P-T2 → P-T3 → P5 → P6 → P7 → P8 → P9 → P10a → P10b → P11 → P12 +P0 → P1a → P1b → P2 (2.0.0) → P-T1 → P-R1 → P-R2 → P3 → P4 → P-T2 → P-T3 → P5 → P6 → P7 → P8 → P9 → P10a → P10b → P11 → P12 + (U1 proposed upstream in parallel; when it lands, a phase deletes catalog-adapter + merge-modes) ``` - **P1a first:** shipped breakage. - **P2 bundles every breaking change into one major:** D2, D4, D6, D9 and the D1 note. -- **P-T1 before P3/P4:** they rewrite the same patch sites, and need the reload test and `patchMethod`. -- **P3/P4 deliver the product rules.** P4 generalizes mode merging and removes the catalog fill. +- **P-T1 before any routing change:** the real-host harness records today's behaviour, which P-R1 must keep. +- **P-R1 is the refactor that matters:** it removes the workaround's moving parts, and everything after it is smaller because of it. - **P10a only after P5–P9:** a move is safe once tests pin what moves. ## Verification (every phase) @@ -261,10 +322,11 @@ Before each commit, grep the diff and `lib/` for local paths. Also: - The phase's first test fails on `main`. -- For P3/P4, a host-level test against the cloned harness logic (`buildModelCatalog`, `discoverModels`, `resolveCallWithInfo`) over the fake host shows: +- For P-R1, P3 and P4, P-T1's real-host harness shows: - the composer is declared-only; - Settings is pi's list plus the missing catalog models; - a user-configured row's efforts and context reach both picker and dispatch unchanged; - - an internal-route toggle model offers Off/On. + - a gap toggle model offers Off/On; + - every gap model reaches its endpoint in its own protocol, on the user's route, with no hidden route registered. - P4's e2e probe confirms the "on" wire value per protocol against the live gateway (opt-in job). - Push, open the PR with a Conventional title, and watch CI to green before starting the next phase. From d4e3067717437fcc2d0fca2fed664ca505112862 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 01:52:49 +0000 Subject: [PATCH 09/17] docs(plan): the plugin owns the plane routes, measured on pi 1.1.0 pi-ai registers exactly the providers in its resolved config, and the fiber keeps whatever the internal/config waterfall returns, while the config editor writes the raw value. A prepended listener that resolves pi-ai's config without opencode/opencode-go therefore keeps pi-ai from claiming them, without touching the user's file, and the plugin registers its own PiAiAdapter under the same ids through the public API. Measured on the real host with pi 1.1.0 and the harness's patch: both routes held by the plugin, pi-ai's other routes unchanged, every protocol on both planes (Gemini on Go included) sent to its own endpoint, a catalog-unknown model id saved, listed and dispatched, and both routes handed back to pi-ai on release. Replaces the upstream asks (no upstream changes) and the save-gate design, which is kept only as the fallback. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- docs/improvement-plan.md | 200 ++++++++++++++++++++++++++------------- 1 file changed, 132 insertions(+), 68 deletions(-) diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 8f3bbac9..80479d0c 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -21,8 +21,8 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which | P1b | `chore:` hygiene | planned | | P2 | `feat!:` the 2.0.0 major | planned | | P-T1 | `test:` a harness that is the real host | planned | -| P-R1 | `refactor:` one route, delegated gaps | planned | -| P-R2 | `fix:` endpoint drift and collapsed modes | planned | +| P-R1 | `refactor:` the plugin owns the plane routes | planned | +| P-R2 | `fix:` the endpoint family, measured | planned | | P3 | `feat:` Settings complements pi's list | planned | | P4 | `feat:` follow the user's configuration; on/off for gap models | planned | | P-T2 | `test:` suite structure | planned | @@ -36,7 +36,6 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which | P10b | `fix:` catalog state | planned | | P11 | `refactor:` UI on the primitives | planned | | P12 | `ci:` lint warnings gating | planned | -| U1 | upstream: per-entry `api`/`baseURL` in llm-pi-ai | to propose | ## Owner decisions @@ -46,13 +45,17 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which | D2 | **Delete `internalRoutes`** | | D3 | **Composer = declared models only. Settings = the whole catalog to pick from.** | | 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, with no hidden routes.** The host already does this per model for every model pi's catalog knows. The plugin fills only the gaps (see "Routing"), through one unregistered adapter per plane that the user's route delegates gap models to. | +| D5 | **Serve every mode on the user's own route ids, with no hidden routes.** `opencode` and `opencode-go` are the plugin's own registered provider (D13), serving every protocol per model (see "Routing"). | | D6 | Shrink the public API in a major release (2.0.0) | | D7 | Merge P0 first, then one PR per phase, each from `main` | | D8 | **The plugin never writes the user's profile and never overrides the user's configuration at dispatch.** It complements what pi's provider list is missing and shows that in Settings when the user configures models. After that, dispatch follows the user's configuration. | | D9 | Retire the deprecated `@viztor/dsh-opencode` wrapper in 2.0.0, after fixing its broken re-export in P1 | | D10 | This document is the plan of record; each phase PR updates its status row | -| D11 | **The adapter patch exists to merge provider modes into the user's provider, nothing else.** It stays (`merge-modes`) and covers every gap model; anything in it that is not merging goes. | +| D11 | **The adapter patch exists to merge provider modes into the user's provider, nothing else.** Once the plugin's provider serves every mode per model (D13), the merge is native and the patch on a host-owned adapter 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 provider under the same ids (F11–F13, run S0). It never deletes another plugin's registration, which the host forbids. | +| D14 | **Target pi 1.1.0, the version the harness pins.** Design, tests and measurements assume it; a newer pi is tolerated, not required. | +| D15 | **Any model id saved on `opencode`/`opencode-go` is accepted and served**, including ids neither pi nor the live catalog knows. The plugin infers what it can, and the user's row decides the rest. | ## What the host supports (read from source) @@ -61,11 +64,10 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which - `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. Delegating at the adapter (F6, F7) keeps `adapterStream`'s validation, projection, replay filtering and failure normalization (`index.ts:1064-1150`) **without** one, so the redirect goes in P-R1. + - 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`). - - **The adapter patch exists to merge provider modes, nothing else (D11).** A declared model that pi cannot serve on the route (a gap, see "Routing") must resolve and dispatch as part of the user's own `opencode`/`opencode-go` provider. The route's adapter therefore hands `listModels`/`resolveModel`/`prepareCall` for that model to the plugin's catalog adapter. Today it does this 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`) is not merging. Under D8 it goes: for a same-mode model, the picker and dispatch both follow the user's row, or pi's data where the user set nothing. + - 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`) is not merging; under D8 the user's row, or pi's data where the user set nothing, answers both picker and dispatch. - **Hiding.** - There is no visibility flag (`LlmProviderInfo = {id, name}`). - `listProviders()` is read by: @@ -109,53 +111,107 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which ## Routing: the problem, the measured facts, the design -**The problem, in the owner's words.** A provider defines one backend. Supporting new models meant un-registering the original provider and registering a new one, which the host does not support; so the plugin mounted extra routes per protocol and merged their models back into the user's provider. +**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 are measured: the data files were counted and the resolver read. F6–F7 are read from the code. P-T1's real-host harness proves them end to end before P-R1 relies on them, and a fact that fails there sends the design back to this section. +**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`; the same in the 0.2.1-alpha.5 build this repo uses (`lib/index.js:689`) | -| F2 | **pi's installed `opencode` catalog spans four protocols, each with its own endpoint.** In pi 1.1.0, which the host pins and patches: Zen has 18 anthropic-messages (`/zen`) and 7 google, 28 completions and 30 responses (`/zen/v1`). Go has 5 anthropic (`/zen/go`) and 19 completions and 6 responses (`/zen/go/v1`), and **no google**. | `@earendil-works/pi-ai@1.1.0` `dist/providers/data/opencode*.json`; harness `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. | `catalog.ts:600-640,681-691,917-921` | -| F4 | **Such a model cannot be saved, listed or dispatched on the user's 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`. | `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. | `catalog.ts:917,922` | +| F1 | **One route already serves every mode, per model.** llm-pi-ai resolves `api` and `baseUrl` per model: `request.api ?? base?.api ?? routeApi` and `request.baseURL ?? base?.baseUrl ?? providerBaseUrl`, where `base` is pi's installed catalog entry. | `llm-pi-ai/src/catalog.ts:917-922` | +| F2 | **pi's installed `opencode` catalog spans four protocols, each with its own endpoint.** In pi 1.1.0, which the host pins and patches: Zen has 18 anthropic-messages (`/zen`) and 7 google, 28 completions and 30 responses (`/zen/v1`). Go has 5 anthropic (`/zen/go`) and 19 completions and 6 responses (`/zen/go/v1`), and **no google**. | `@earendil-works/pi-ai@1.1.0` `dist/providers/data/opencode*.json`; the harness's `pnpm-lock.yaml:13` | +| F3 | **A model pi's catalog does not know cannot get its own protocol.** A profile entry has no `api`/`baseURL` field (`PiAiModelProfile`); it takes the route's `api`, or `sharedCatalogApi`, which is undefined for a multi-protocol catalog. | `llm-pi-ai/src/catalog.ts:600-640,681-691,917-921` | +| F4 | **Such a model cannot be saved, listed or dispatched on a pi-ai route.** Writes are validated strictly (`assertServiceable`), so the save is rejected with "needs an api". Stored reads are deferred, so the id lands in `modelErrors`. `listModels` lists only resolved models, and `modelOf` throws `INVALID_CONFIG`. | `llm-pi-ai/src/config.ts:357-368,487`; `index.ts:171`; `adapter.ts:271-311` | +| F5 | **A route-level `api` or `baseURL` overrides every model**, collapsing the modes onto one protocol or endpoint. | `llm-pi-ai/src/catalog.ts:917,922` | | F6 | **Dispatch reads the route's adapter.** `selectModel`, `prepareCall` and `llm.stream` all go through `registration.adapter.resolveModel`/`prepareCall`. The host requires only that the prepared model's `provider` equals the route id, and then dispatches through `adapterCall.stream`, with validation, projection, replay filtering and failure normalization intact. | `llm/src/index.ts:774-793,894-905,956-964,1064-1150` | -| F7 | **An adapter that is never registered can share the route's id.** The host checks duplicates only in its registry, so a plugin-owned `PiAiAdapter` whose profile is keyed `opencode`/`opencode-go` can answer for that route when the route's own adapter delegates to it. The Gemini bridge already builds such an adapter by hand. | `llm/src/index.ts:399-460`; `src/google-bridge.ts:541-600` | - -**So the plugin has exactly three gaps to fill, and nothing else:** - -- **G1:** models newer than pi's catalog on a plane, which includes every Gemini model on Go (F2); -- **G2:** endpoint drift, where pi's stored bases are retired or are the wrong family for the deployment's `endpoint`; -- **G3:** a profile-level `api`/`baseURL` that collapses the modes (F5). - -Stale metadata for models pi knows is **not** a gap: under D8 the user's row, or pi, decides. - -**The design ("one route, delegated gaps"):** - -- **`host/catalog-adapter.ts`.** One `PiAiAdapter` per plane, built from the host's own llm-pi-ai class, as the bridge does today. Its profile is keyed by the user's route id and holds only the gap models, as pi `Model`s with their own `api` and `baseUrl` (from the catalog, family-aware). The user's row values come first (D8). It is **never registered**. -- **`host/merge-modes.ts`, the adapter patch (D11).** On the user's plane route adapter only: - - `listModels` appends the declared gap ids; - - `resolveModel` and `prepareCall` hand a gap model, with the same arguments, to the catalog adapter; - - everything else is the user's route answering as it always does. - - Picker, validation and dispatch therefore read one answer, through the host's own path (F6). +| F7 | **`registerAdapter` is all-or-nothing against a private map**, and only a registration's owner moves its routes (`handle.replace`). A route nobody holds is free to register. | `llm/src/index.ts:399-460` | +| F8 | **Config resolution is a public, synchronous waterfall.** Cordis runs `internal/config` listeners in order, each getting `(config, next)`; `prepend: true` puts a listener outside those registered before it. pi-ai's save check (`assertServiceable`) is one listener on it. | the harness's `vendor/cordis/src/events.ts:234-258`; `llm-pi-ai/src/index.ts:177-186` | +| F9 | **opencode's own client serves four protocols from one provider, per model.** It picks the SDK from `model.provider.npm` and the endpoint from `model.provider.api ?? provider.api`, both from the catalog. The plugin's catalog carries the same data (`catalog-data.ts` `OPENCODE_FAMILY_API`, a per-model SDK). | opencode's `packages/core/src/models-dev.ts:85-93` | +| F10 | **pi 1.1.0's `opencode-go` provider has no Google API.** Its api map is anthropic-messages, openai-completions and openai-responses only, each wrapped in `withOpenCodeSessionHeader`. pi exports each protocol separately (`api/*.lazy`: `anthropicMessagesApi`, `openAICompletionsApi`, `openAIResponsesApi`, `googleGenerativeAIApi`) plus `createProvider` (`models`), which is what `google-bridge.ts` `googleProviderFor` already composes from. | pi 1.1.0 `dist/providers/opencode-go.js`; its `package.json` `exports` | +| F11 | **pi-ai's routes are exactly the keys of its _resolved_ config.** It registers `[...profiles().keys()]`, and `profiles()` reads `config.providers.get()`, the value the fiber resolved. | `llm-pi-ai/src/index.ts:166-175,293-312` | +| F12 | **Every path to that resolved value goes through the `internal/config` waterfall, and the fiber keeps the waterfall's _return value_.** That covers the first load, `fiber.update`, the loader's volatile commit, and the config editor's validation. **The config editor writes the raw `next` to `cordis.patch.yml`, never the resolved value.** So an outermost listener that returns a copy without `providers.opencode`/`providers["opencode-go"]` keeps pi-ai from seeing or registering them, while the user's file keeps every entry. | the harness's `vendor/cordis/src/fiber.ts:641-643,736-752` and `vendor/loader/src/config/entry.ts:164-195`; `boot/config-editor/src/index.ts:101-123` | +| F13 | **The plugin can then register those ids through the public API.** With no pi-ai entry for them, `registerAdapter(["opencode", "opencode-go"], adapter)` passes the duplicate check. `PiAiAdapter` is a **public export** of `@deepseek-ai/dsh-llm-pi-ai`. pi-ai's Settings directory keeps listing both ids, because it lists every installed catalog provider regardless of profiles, with `settingsNs` = pi-ai and `settingsPath = ["providers", id]`. So Settings edits the same entries in the same file as today. | `llm/src/index.ts:399-460`; `llm-pi-ai/src/index.ts:77,128-150` | + +**What the plugin has to add, and nothing else:** + +- **G1:** any declared model the host's pi cannot resolve (D15): models newer than pi 1.1.0's catalog (8 Zen, 4 Go, including every Gemini model on Go, F10) **and** ids the catalog doesn't know at all; +- **G2:** the endpoint family. pi 1.1.0 stores the legacy bases; the plugin sends the per-protocol `inference` bases (or the legacy ones, per the `endpoint` setting); +- **G3:** a profile-level `api`/`baseURL` (F5) is the user's explicit choice and is honoured as such (D8), rather than silently collapsing the modes. + +### The design: the plugin owns the plane routes (D13) + +- **`host/route-claim.ts`.** One `internal/config` listener, `prepend: true`, active only for llm-pi-ai's fiber. It **mutates nothing**: it runs the same waterfall on a copy without the two plane providers, and returns that result. + + ```ts + ctx.on( + "internal/config", + function (raw, next) { + if (nested || !isPiAiFiber(this)) return next(); + const rest = withoutPlaneProviders(raw); // a fresh copy; undefined when none declared + if (rest === undefined) return next(); + nested = true; + try { + return this.ctx.waterfall(this, "internal/config", rest, () => rest); + } finally { + nested = false; + } + }, + { prepend: true } + ); + ``` + + - pi-ai validates and serves everything else exactly as before. Its "needs an api" check never sees a plane entry, so **any model id saves** (D15), with no save gate and no error parsing. + - The listener records the plane entries it set aside: they are the user's configuration for the plugin's provider (D8). + - **Ordering:** pi-ai is usually active before the plugin mounts, so the plugin calls pi-ai's `fiber.restart()` (public) once, and pi-ai's config is resolved again through the listener. On dispose the listener goes with the plugin's fiber, the plugin releases its registration, and it restarts pi-ai once more, so pi-ai takes the routes back. + - Mutating the config in place does **not** work. Cordis passes listeners a tracing proxy, so an in-place trim leaks into what the fiber keeps and into what the editor writes. A first prototype measured exactly that. + +- **`host/plane-provider.ts`.** One `PiAiAdapter` (the public export), registered with `ctx.llm.registerAdapter(["opencode", "opencode-go"], adapter)`. Its profiles are rebuilt from the set-aside entries whenever they change. + - **Its pi provider is a composite** (F10): `createProvider({ id: route, api: { every protocol: withOpenCodeSessionHeader(Api()) }, models })`, loaded from the **host's** pi through the loader lookup `google-bridge.ts` already does. + - **Every declared model**, known or not, comes from one pure function, `utils/plane-model.ts` `planeModelFor({ id, route, declared, piModel, spec, family })`, the way opencode's own client builds them (F9). Every field takes the first source that has it: + + | field | 1st | 2nd | 3rd | 4th | + | :-- | :-- | :-- | :-- | :-- | + | protocol (`api`) | the user's route-level `api` | the catalog spec's SDK | pi's built-in entry, then the id's family (`protocolFromModelId`, moved to `utils/protocols.ts`) | `openai-completions`, which both planes serve for every family | + | `baseUrl` | the user's route-level `baseURL` | the `endpoint` family for that protocol: the per-protocol `inference` bases by default (what 1.4.1 already sends); pi 1.1.0's legacy bases when the setting says so | | | + | name, context, max tokens, modalities, cost | the user's declared row | the catalog spec | pi's built-in entry | pi `Model` defaults | + | reasoning levels | the user's `reasoningEfforts` | `utils/model-metadata.ts` `reasoningFor(spec)` (D1: toggle → Off/On) | pi's built-in entry | none | + + - An id with no catalog spec, no pi entry and no declared row still gets a model (completions, defaults), so it lists, validates and dispatches. The gateway decides whether it exists, as it would for opencode itself. + - With no `models` list declared, the route serves the plane's whole servable catalog, as pi's catalog route does today. + - The user's route-level `apiKeyEnv` and `headers` are honoured, and the key resolves through the plugin's credential resolver (P6). - **Deleted:** - the llm-pi-ai child mount and its `llmFacade` Proxy; - every hidden route and its tables (`responses-routes.ts`, the hidden ids in `DEFAULT_PROVIDERS`); - the listing hide (`listProviders`, `listConfigurableProviders`, `listModels`); - the stream redirect and `isAvailable`; - - the Gemini bridge registration, which becomes ordinary G1 models; - - the Google history and replay translation, if the delegated path keeps replay on one provider label (verify by test); + - the adapter patch on a host-owned adapter (`decoratePlaneAdapters`), because the route's adapter is now the plugin's own; + - the Gemini bridge registration, which becomes ordinary models on the plugin's provider; + - the Google history and replay translation, if replay stays on one provider label (verify by test); - `internalRoutes` (D2). -- **What remains private:** one seam, the registry's adapter map, to wrap the user's route adapter, plus the hand-built profile shape. Both are pinned by contract tests against the real `@deepseek-ai/dsh-llm` and `@deepseek-ai/dsh-llm-pi-ai` packages (already in `node_modules`), so a host upgrade that changes either fails CI rather than a user's turn. +- **What remains private:** one seam, the `ResolvedPiAiProviderProfile` shape handed to the public `PiAiAdapter` (already used today, by the Gemini bridge). A contract test against the real `@deepseek-ai/dsh-llm-pi-ai` pins it, so a host upgrade that changes it fails CI rather than a user's turn. +- **What the user sees:** the same two providers in the composer and in Settings, under the same names. They now serve every protocol, every catalog model and any custom id, on the plugin's endpoints. Remove the plugin, and pi-ai serves them again from the same file. + +### Measured on the real host -**The limit no plugin can remove (F4).** A user cannot **save** a gap model through Settings, because pi-ai rejects the write. Today such models work only when declared in the deployment's config file, which is read in deferred mode. D3's "whole catalog in Settings" is therefore fully deliverable only with upstream change U1. +Runs A–D: published `@deepseek-ai/dsh` 0.2.1-rc.2 with the pi 1.4.0 it installs, offline, with an instrumented fetch. Run S0 swaps pi to **1.1.0 plus the harness's own patch** (D14). The prototypes are throwaway, and not in the repository. -**Upstream changes to deepseek-harness, in order of value:** +| 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: pi 1.1.0 + route claim + plugin provider** | `opencode`, `opencode-go` → **the plugin's adapter**; `deepseek`, `deepseek-official`, `deepseek-account` → pi-ai, 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`, with the same paths under `/inference/go/` on Go, **Gemini on Go included** | Zen `gpt-5.3-codex-spark` → `/responses` (id family), 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, then it is listed, validates and is sent as completions | -- **U1:** a `models` entry, and `modelOverrides`, accept their own `api` and `baseURL`. In `resolveEntry`, `entry.api ?? request.api ?? base?.api ?? routeApi`, and the same for `baseURL`. `LlmDiscoveredModel` gains `api` (and `reasoning`), so a Settings adoption writes it. **With U1, pi serves every declared model natively, and `catalog-adapter`, `merge-modes` and the registry seam are deleted.** The plugin's routing layer becomes zero code, and what remains is the Settings complement. -- **U2:** a `resolveProfiles` export, so the catalog adapter's profile is built by llm-pi-ai rather than by hand (only needed until U1). -- Withdrawn: `registerAdapter(…, { listed: false })`. Without hidden routes nothing needs hiding. +Two more S0 checks: + +- **Handback.** With the listener removed, restarting pi-ai gives it both routes back. It then lists what pi 1.1.0 knows, and the file is untouched. +- **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, not this design. + +Left for P-R1's tests: + +- the brief window at startup while pi-ai restarts (its other routes re-register with it); +- whether the gateway serves both endpoint families. That can't be measured from a sandbox that blocks `opencode.ai`, so P-R2 measures it in CI's live e2e job: a keyless request is enough, because 401 means the path exists and 404 means it doesn't. + +**Fallback, only if P-T1's harness contradicts S0:** run C's shape. That means an unregistered catalog adapter per plane serving the gap models; an adapter patch on pi-ai's route adapter delegating them; and a non-mutating save gate that validates a copy without the unresolvable ids, then absorbs only pi-ai's "needs an api" refusal for those ids on those two routes. ## Target architecture @@ -167,21 +223,22 @@ src/ patch-method.ts patch a host method: restore only if still current; delete when the original was inherited; read "unpatched" from the prototype; re-wrap on llm/adapters-updated discovery.ts discoverModels: pi's answer + the catalog's missing models (D3, D8) - catalog-adapter.ts one unregistered PiAiAdapter per plane, keyed by the user's route id, - holding only the gap models (G1-G3) with their own api and baseUrl - merge-modes.ts the adapter patch on the user's route: gap models are listed, resolved - and prepared by the catalog adapter; nothing else is touched (D11) + route-claim.ts prepended internal/config listener: pi-ai resolves its config without the + two plane providers, so it never claims them; restarts pi-ai on mount/dispose + plane-provider.ts the plugin's PiAiAdapter, registered as opencode + opencode-go, built from + the user's entries with a composite pi provider and the plugin's endpoints 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, model-metadata (reasoning, both vocabularies), credential-policy, + utils/ pure: protocols, plane-model (one pi Model per declared id, F9 precedence), + model-metadata (reasoning, both vocabularies), credential-policy, request-shape, session-id, google-replay, 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 ``` -There are no hidden routes, no listing hide, no stream redirect, no child mount, no Gemini bridge registration, and no `resolveModelInfo` override (D8). One private seam is left, the registry's adapter map that `merge-modes` reaches, and it is pinned by a contract test against the real host packages and deleted by U1. +There are no hidden routes, no listing hide, no stream redirect, no child mount, no Gemini bridge registration, no patched host adapter and no `resolveModelInfo` override (D8). One private seam is left, the `ResolvedPiAiProviderProfile` shape handed to the public `PiAiAdapter`, and a contract test against the real host packages pins it. ## Phases @@ -209,35 +266,43 @@ One PR each, from `main`, after P0 merges. The first commit of each phase is a t - Stop publishing `@viztor/dsh-opencode` (D9) and set its final deprecation message. - Document the D1 effort break here, so every breaking change ships in one 2.0.0. The `BREAKING CHANGE:` footer lists all of them. 4. **P-T1 `test:` a harness that is the real host.** + - Pin the dev dependency `@earendil-works/pi-ai` to **1.1.0** (D14), with a test that fails if it drifts from the harness's lockfile pin. - Build `test/support/host.ts` on the **real** packages already in `node_modules`: a cordis `Context`, `@deepseek-ai/dsh-llm`'s `LlmRuntime` and `@deepseek-ai/dsh-llm-pi-ai`'s `apply`. It is pointed at the mock gateway (`test/mock/`) through `fetch`, with a profile builder for the deployment shapes that matter: no `models` list, a `models` list of known ids, gap ids in the config file, and a route-level `baseURL`/`api`. - **Characterization tests** that record today's behaviour across those shapes: for each model, the endpoint and protocol it reaches, and what the composer (`buildModelCatalog`'s logic), validation (`resolveCallConfig`) and Settings (`discoverModels`) report. They are the safety net for P-R1, and the measurement behind G1–G3. - - **Contract tests** that pin the two private seams (the registry adapter map, and the hand-built llm-pi-ai profile shape) against the real packages, so an upgrade breaks CI, not a user. + - **Contract tests** against the real packages, so an upgrade breaks CI, not a user. They pin: + - the profile shape; + - F11, that pi-ai's routes are the keys of its resolved config; + - F12, that the waterfall's return value is what the fiber keeps, and that the editor writes the raw value. + - Replay run S0 as tests on this harness. Its result confirms the design for P-R1, or sends it to the fallback. - Retire the ~60 ad-hoc contexts (21 with no-op `effect`), `createMockContext` and `HarnessSimulator`. - Add `host/patch-method.ts` (restore only if still current; delete when inherited; re-wrap on `llm/adapters-updated`), and `test/reload.test.ts` (apply A → apply B → dispose A → B intact → dispose B → originals) for the sites that remain. -5. **P-R1 `refactor:` one route, delegated gaps (D5, D11).** The core of the refactor; the steps are in "Routing". - - `host/catalog-adapter.ts`: one unregistered `PiAiAdapter` per plane, keyed by the user's route id, built from the gap models. - - `host/merge-modes.ts`: the user's route adapter lists, resolves and prepares gap models through it. - - Delete the hidden-route machinery: the child mount and its facade Proxy, `responses-routes.ts`, the listing hide, the stream redirect and `isAvailable`, the Gemini bridge registration, the hidden ids in `DEFAULT_PROVIDERS` and `cordis.patch.yml`, and the tests that pin them. +5. **P-R1 `refactor:` the plugin owns the plane routes (D5, D13).** The core of the refactor; the steps are in "Routing". + - `host/route-claim.ts`, `host/plane-provider.ts` and `utils/plane-model.ts`. Tests run against the real llm-pi-ai, llm and cordis: + - after mount, `opencode`/`opencode-go` are the plugin's adapter, and every other pi-ai route is unchanged; + - a known model, a pi-1.1.0 gap model and **a catalog-unknown id** each save through `configEditor.edit` (the file holds exactly what was saved), list, validate and dispatch (D15); + - a broken entry on any other pi-ai provider is still rejected; + - the listener never writes to the user's config object; + - mount while pi-ai is active, dispose, and remount: the routes move to the plugin, back to pi-ai, and to the plugin again, with no `DUPLICATE_ADAPTER`. + - Delete the hidden-route machinery: the child mount and its facade Proxy, `responses-routes.ts`, the listing hide, the stream redirect and `isAvailable`, `decoratePlaneAdapters`, the Gemini bridge registration, the hidden ids in `DEFAULT_PROVIDERS` and `cordis.patch.yml`, and the tests that pin them. - The Google replay translators go too, if the characterization tests show replay stays on one provider label. - **The P-T1 characterization tests must pass unchanged**, except the rows that recorded a defect, each named in the PR. - **New tests:** - - a gap model on each plane, in each protocol, reaches its endpoint; - - a known model is answered by pi alone; - - a user's row for a gap model (name, context, max tokens, modalities, efforts) wins over the catalog. + - every model on each plane, in each protocol, reaches its endpoint in the chosen family, **Go Gemini included** (F10); + - `planeModelFor` table tests: each field's precedence (route-level override > declared row > catalog > pi > defaults; SDK > pi > id family > completions; both endpoint families); + - a user's row (name, context, max tokens, modalities, efforts) wins end to end, in picker and dispatch. - Expected size: a net deletion of well over a thousand lines. -6. **P-R2 `fix:` endpoint drift and collapsed modes (G2, G3).** - - Measure first, with P-T1's harness and the opt-in e2e: which of pi 1.1.0's bases (`/zen`, `/zen/v1`, `/zen/go`, `/zen/go/v1`) are retired or wrong for the deployment's `endpoint` family, and what a route-level `baseURL`/`api` does to known models. - - The delegation rule becomes "gap model, or a known model whose resolved endpoint or protocol is wrong". The catalog adapter serves it with the right base. - - If nothing is wrong, this phase only adds the tests that say so. +6. **P-R2 `fix:` the endpoint family, measured (G2).** + - Measure, with the opt-in live e2e, which family the gateway serves for each protocol on each plane: the `inference` bases and pi 1.1.0's legacy ones (`/zen`, `/zen/v1`, `/zen/go`, `/zen/go/v1`). + - Set the measured default, and pin it with tests. 7. **P3 `feat:` Settings complements pi's list (D3, D8).** - The `discoverModels` wrapper returns pi's answer plus the plane's **servable, non-deprecated** catalog models missing from it. It dedupes, and whitelists to `LlmDiscoveredModel`'s fields. - - **F4 limits this:** pi-ai rejects saving a gap model, so until U1 these rows are offered with that caveat in their name/description, or held back behind U1. This needs an owner decision. + - Gap rows save like any other (D15), and the composer then shows them, served by the plugin's provider. - The composer's `listModels` stays declared-only. - Delete `installModelDiscovery`/`toDiscovered` and P0's two discovery-reload tests. - Rewrite the "adds nothing" tests (`test/models-discovery.test.ts:207-210,307-319`) and the contradicting docs (`README.md:220`, `deep-dive.md:14,174-175`, `redirect-planes.md:102`). `deep-dive.md:14`'s "one wire protocol per route" premise is false for this host (F1). 8. **P4 `feat:` follow the user's configuration; on/off for gap models (D1, D8).** - Remove the catalog fill for models pi knows: the plane-route overrides in `decorateModelInfo`, and the same-mode fill inside the current adapter patch. The user's row, or pi, answers. - - For gap models, `utils/model-metadata.ts` `reasoningFor(spec)` builds the catalog adapter's levels in both vocabularies. A toggle model offers **Off/On**; old Minimal/Xhigh/Max selections break (documented in P2). + - For models with no user row, `utils/model-metadata.ts` `reasoningFor(spec)` builds the plugin provider's levels in both vocabularies. A toggle model offers **Off/On**; old Minimal/Xhigh/Max selections break (documented in P2). - Delete the duplicate `ALL_THINKING_LEVELS` and the three capitalisation copies. - **Tests:** for every gap model on each plane, picker efforts = the levels dispatch accepts; for a known model, both equal pi's or the user's row; the "on" wire value is verified per protocol with an e2e probe. 9. **P-T2 `test:` suite structure.** @@ -259,9 +324,9 @@ One PR each, from `main`, after P0 merges. The first commit of each phase is a t 12. **P6 `refactor:` one credential resolver.** - Parity test over the six deciders. - `utils/credential-policy.ts` + `credentials.ts`; the placeholder filter applied once. - - The catalog adapter's key resolution is the same resolver, store fallback included. + - The plugin provider's key resolution is the same resolver, store fallback included. - `CaptureStore` per root, evicting a key on 401. - - No `process.env` in `fetch-patch`, `lifecycle` or the catalog adapter. + - No `process.env` in `fetch-patch`, `lifecycle` or the plugin provider. 13. **P7 `refactor:` one source per table.** - `utils/protocols.ts`: SDKs, one protocol map and one plane table (with the hidden routes gone after P-R1, the per-route tables go too), and one `planeForRoute` (four copies today). - One `formatUsd`. @@ -276,7 +341,7 @@ One PR each, from `main`, after P0 merges. The first commit of each phase is a t - The parity test reads the contracts. 16. **P10a `refactor:` pure module moves, no behaviour change.** - `models-discovery` → `host/*`. - - What is left of `responses-provider` and `google-bridge` after P-R1 → `host/catalog-adapter.ts`, with the loader lookups moved into `cordis-context.ts`. + - What is left of `responses-provider` and `google-bridge` after P-R1 → `host/plane-provider.ts`, with the loader lookups moved into `cordis-context.ts`. - `go-discovery` → `credentials.ts`. - `usage-ui` → css/time/state/copy; `parseFailure` → the usage contract. - `fetch-patch` → `request/`. @@ -291,13 +356,10 @@ One PR each, from `main`, after P0 merges. The first commit of each phase is a t - Re-measure the bundle budget. 19. **P12 `ci:`** make lint warnings gating once `src/` is at zero. -**Upstream asks (deepseek-harness):** U1 and U2 are described in "Routing". U1 deletes `catalog-adapter`, `merge-modes` and the last private seam. - ## Order ``` P0 → P1a → P1b → P2 (2.0.0) → P-T1 → P-R1 → P-R2 → P3 → P4 → P-T2 → P-T3 → P5 → P6 → P7 → P8 → P9 → P10a → P10b → P11 → P12 - (U1 proposed upstream in parallel; when it lands, a phase deletes catalog-adapter + merge-modes) ``` - **P1a first:** shipped breakage. @@ -327,6 +389,8 @@ Also: - Settings is pi's list plus the missing catalog models; - a user-configured row's efforts and context reach both picker and dispatch unchanged; - a gap toggle model offers Off/On; - - every gap model reaches its endpoint in its own protocol, on the user's route, with no hidden route registered. + - every model reaches its endpoint in its own protocol, on the user's route id, with no hidden route registered; + - a model id the catalog has never heard of saves through Settings' `configEditor` path and dispatches; + - all of it on pi 1.1.0. - P4's e2e probe confirms the "on" wire value per protocol against the live gateway (opt-in job). - Push, open the PR with a Conventional title, and watch CI to green before starting the next phase. From a38259ce913d399f3c3f1c8e07a03523f16cf52b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:09:43 +0000 Subject: [PATCH 10/17] docs(plan): fold the reviewed design and the build order into the plan A nine-lens review of the routing design, with two independent skeptics per finding (78 of 82 upheld), changed five things before any code moves: - Settings reads pi-ai's resolved config, so a read-only wrapper on settings.describe puts the plane entries back into the displayed value; writes were never affected, the Models page edits by path. - The route claim gets an explicit lifecycle: commits are told from validations by identity, reconcile runs on commit, mount and teardown are ordered, and pi-ai restarts at most once per transition. - The plugin's adapter is built from public pieces only, with pi-ai's credential rule and no captured keys on the adapter path. - The protocol comes from the catalog map, never from a guess at the model's name; metadata and levels are catalog first. - Releases stay on 1.x, phases land as stacked pull requests, and a real host harness plus a binary e2e job pin the behaviour first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- docs/improvement-plan.md | 694 +++++++++++++++++++++++++-------------- 1 file changed, 454 insertions(+), 240 deletions(-) diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 80479d0c..5a7b629a 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -5,7 +5,7 @@ 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; +- an adversarial review of the plan itself, then a nine-lens review (five reviewers, four investigators) in which two independent skeptics checked every finding: 78 of 82 were upheld and are folded in here; - the host's own source and data (`deepseek-ai/deepseek-harness`, and the pi 1.1.0 catalog it pins). File references into the host are relative to that repository's `packages/`. **Outcome:** a plugin that only uses host extension points the host supports; one source of truth per decision; a suite that pins behaviour before it moves; and the owner's product rules below. @@ -14,48 +14,58 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which ## Status -| Phase | Title | State | -| :-- | :-- | :-- | -| P0 | suite hardening, 11 defect fixes, this plan | in review | -| P1a | `fix:` shipped breakage | planned | -| P1b | `chore:` hygiene | planned | -| P2 | `feat!:` the 2.0.0 major | planned | -| P-T1 | `test:` a harness that is the real host | planned | -| P-R1 | `refactor:` the plugin owns the plane routes | planned | -| P-R2 | `fix:` the endpoint family, measured | planned | -| P3 | `feat:` Settings complements pi's list | planned | -| P4 | `feat:` follow the user's configuration; on/off for gap models | planned | -| P-T2 | `test:` suite structure | planned | -| P-T3 | `ci:` CI and scripts | planned | -| P5 | `refactor:` installers | planned | -| P6 | `refactor:` one credential resolver | 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 | +Phases land as **stacked pull requests**. Each one is based on the previous phase's branch, and the first stacks on P0. Every phase is a 1.x release: there is no major (D6). + +| Phase | Title | State | +| :---- | :-------------------------------------------- | :-------- | +| P0 | suite hardening, 12 defect fixes, this plan | in review | +| P1a | `fix:` shipped breakage | planned | +| P1b | `chore:` hygiene | planned | +| P2 | `feat:` a smaller public API | planned | +| P-T1a | `build:` pin the host | planned | +| P-T1b | `test:` a harness that is the real host | planned | +| P-H | `refactor:` one way to patch a host method | planned | +| P-R1 | `feat:` the plugin owns the plane routes | planned | +| P-R2 | `fix:` the endpoint family, measured | planned | +| P3 | `feat:` Settings complements pi's list | planned | +| P-T2 | `test:` suite structure | planned | +| P-T3 | `ci:` CI and scripts | planned | +| P5 | `refactor:` installers | planned | +| P6 | `refactor:` the fetch patch's credential fill | planned | +| P7 | `refactor:` one source per table | planned | +| P8 | `refactor:` settings table | planned | +| P9 | `refactor:` remote seam | planned | +| P10a | `refactor:` pure module moves | planned | +| P10b | `fix:` catalog state | planned | +| P11 | `refactor:` UI on the primitives | planned | +| P12 | `ci:` lint warnings gating | planned | ## Owner decisions | # | Decision | | :-- | :-- | -| D1 | A model whose reasoning is only on/off is offered as **Off / On**. Old saved Minimal/Xhigh/Max selections on such models **break, and the break is documented**. | -| D2 | **Delete `internalRoutes`** | -| D3 | **Composer = declared models only. Settings = the whole catalog to pick from.** | +| 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.** `opencode` and `opencode-go` are the plugin's own registered provider (D13), serving every protocol per model (see "Routing"). | -| D6 | Shrink the public API in a major release (2.0.0) | -| D7 | Merge P0 first, then one PR per phase, each from `main` | -| D8 | **The plugin never writes the user's profile and never overrides the user's configuration at dispatch.** It complements what pi's provider list is missing and shows that in Settings when the user configures models. After that, dispatch follows the user's configuration. | -| D9 | Retire the deprecated `@viztor/dsh-opencode` wrapper in 2.0.0, after fixing its broken re-export in P1 | -| D10 | This document is the plan of record; each phase PR updates its status row | -| D11 | **The adapter patch exists to merge provider modes into the user's provider, nothing else.** Once the plugin's provider serves every mode per model (D13), the merge is native and the patch on a host-owned adapter is deleted. | +| D5 | **Serve every mode on the user's own route ids, with no hidden routes.** | +| D6 | **Shrink the public API in a 1.x minor.** There is no 2.0.0: nobody consumes the API. Titles are `feat:`, `fix:`, `refactor:` and so on, with no `!` and no `BREAKING CHANGE` footer. Behaviour changes are named in each PR body and in the README's "Changed in 1.x" notes. | +| D7 | Stacked pull requests: one phase per branch, each based on the previous one. | +| D8 | **The plugin never writes the user's profile on its own, and never overrides the user's configuration at dispatch.** The only write is the card's explicit purge action, which goes through `configEditor`. | +| D9 | Stop publishing `@viztor/dsh-opencode` in P2. The deprecation message already published ("Renamed to …") stays as the final one. | +| D10 | This document is the plan of record. Each phase PR updates its status row. | +| D11 | The adapter patch existed to merge modes. Once the plugin's provider serves every mode per model (D13), the merge is native and the patch is deleted. | | D12 | **No upstream changes.** Everything is done inside the current host with the APIs it has. | -| D13 | **The plugin owns the `opencode`/`opencode-go` routes.** pi-ai is kept from claiming them through the config waterfall, and the plugin registers its own provider under the same ids (F11–F13, run S0). It never deletes another plugin's registration, which the host forbids. | -| D14 | **Target pi 1.1.0, the version the harness pins.** Design, tests and measurements assume it; a newer pi is tolerated, not required. | -| D15 | **Any model id saved on `opencode`/`opencode-go` is accepted and served**, including ids neither pi nor the live catalog knows. The plugin infers what it can, and the user's row decides the rest. | +| D13 | **The plugin owns the `opencode`/`opencode-go` routes.** pi-ai is kept from claiming them through the config waterfall, and the plugin registers its own adapter under the same ids (F11–F14, run S0). It never deletes another plugin's registration, which the host forbids. | +| D14 | **Target pi 1.1.0, the version the harness pins.** Installs of the published host resolve `^1.1.0` to the newest 1.x (1.4.0 today), so CI runs both. | +| D15 | **Any model id saved on `opencode`/`opencode-go` is accepted and served**, including ids neither pi nor the live catalog knows. | +| D16 | **The protocol comes from the catalog, never from a guess at the model's name.** The order is: the catalog's SDK through `PROTOCOL_FOR_SDK`, then pi's own catalog entry, then the user's route-level `api`, then `openai-completions`. `protocolFromModelId` is deleted. | +| D17 | **Precedence is catalog first.** Metadata and reasoning levels come from the user's model row, then pi-ai's route-level `modelOverrides`, then the catalog, then pi, then the defaults. | +| D18 | **Settings keeps showing the planes.** One read-only wrapper on `settings.describe` puts the plane entries back into pi-ai's displayed value (F20). The user's config is never written: the Models page writes only through `settings.mutate` path edits (`client/ui-settings-models/src/client/operations.ts:87`). | +| D19 | **Plugin settings.** Delete `modelOverrides`, `maxContextWindow`, `modelRoutes` and `internalRoutes`, and the README's nonexistent `hiddenModels`. Keep `endpoint` (`legacy` or `modern`, with `auto` an alias of `modern`). Keep `providers`, redefined as the route ids whose turns the plugin decorates, with both planes always included. | +| D20 | **The plane adapter resolves keys by pi-ai's rule.** A named ref resolves through the credentials service, then the launch environment, then fails with `MISSING_CREDENTIAL` and sends no request. With no ref, pi's ambient key applies, plus the `readOpencodeStore` opt-in. Captured and literal keys never feed the adapter; they stay in the fetch patch's fill for an empty or placeholder wire key. | +| D21 | The keyless paid-model filter on listings is dropped (D3, D8). Without a key, dispatch fails loudly. | +| D22 | The `@deepseek-ai/*` dev dependencies move to the version the published `dsh` resolves (0.2.1-rc.2). It is named once, in `test/support/host-versions.ts`. | ## What the host supports (read from source) @@ -67,7 +77,7 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which - 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`) is not merging; under D8 the user's row, or pi's data where the user set nothing, answers both picker and dispatch. + - 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: @@ -130,88 +140,146 @@ The result of an architecture audit of the tree at 1.4.1, and the order in which | 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:** any declared model the host's pi cannot resolve (D15): models newer than pi 1.1.0's catalog (8 Zen, 4 Go, including every Gemini model on Go, F10) **and** ids the catalog doesn't know at all; -- **G2:** the endpoint family. pi 1.1.0 stores the legacy bases; the plugin sends the per-protocol `inference` bases (or the legacy ones, per the `endpoint` setting); -- **G3:** a profile-level `api`/`baseURL` (F5) is the user's explicit choice and is honoured as such (D8), rather than silently collapsing the modes. +- **G1:** serve any declared model the host's pi cannot resolve (D15). That means models newer than pi 1.1.0's catalog (8 Zen and 4 Go, including every Gemini model on Go, F10), **and** ids the catalog doesn't know at all. +- **G2:** the endpoint family. pi 1.1.0 stores the legacy bases; the plugin sends the per-protocol `inference` bases, or the legacy ones when the `endpoint` setting says so. +- **G3:** a profile-level `api`/`baseURL` (F5). It is the user's explicit choice and is honoured as one (D8), without collapsing the modes. ### The design: the plugin owns the plane routes (D13) -- **`host/route-claim.ts`.** One `internal/config` listener, `prepend: true`, active only for llm-pi-ai's fiber. It **mutates nothing**: it runs the same waterfall on a copy without the two plane providers, and returns that result. - - ```ts - ctx.on( - "internal/config", - function (raw, next) { - if (nested || !isPiAiFiber(this)) return next(); - const rest = withoutPlaneProviders(raw); // a fresh copy; undefined when none declared - if (rest === undefined) return next(); - nested = true; - try { - return this.ctx.waterfall(this, "internal/config", rest, () => rest); - } finally { - nested = false; - } - }, - { prepend: true } - ); - ``` - - - pi-ai validates and serves everything else exactly as before. Its "needs an api" check never sees a plane entry, so **any model id saves** (D15), with no save gate and no error parsing. - - The listener records the plane entries it set aside: they are the user's configuration for the plugin's provider (D8). - - **Ordering:** pi-ai is usually active before the plugin mounts, so the plugin calls pi-ai's `fiber.restart()` (public) once, and pi-ai's config is resolved again through the listener. On dispose the listener goes with the plugin's fiber, the plugin releases its registration, and it restarts pi-ai once more, so pi-ai takes the routes back. - - Mutating the config in place does **not** work. Cordis passes listeners a tracing proxy, so an in-place trim leaks into what the fiber keeps and into what the editor writes. A first prototype measured exactly that. - -- **`host/plane-provider.ts`.** One `PiAiAdapter` (the public export), registered with `ctx.llm.registerAdapter(["opencode", "opencode-go"], adapter)`. Its profiles are rebuilt from the set-aside entries whenever they change. - - **Its pi provider is a composite** (F10): `createProvider({ id: route, api: { every protocol: withOpenCodeSessionHeader(Api()) }, models })`, loaded from the **host's** pi through the loader lookup `google-bridge.ts` already does. - - **Every declared model**, known or not, comes from one pure function, `utils/plane-model.ts` `planeModelFor({ id, route, declared, piModel, spec, family })`, the way opencode's own client builds them (F9). Every field takes the first source that has it: - - | field | 1st | 2nd | 3rd | 4th | - | :-- | :-- | :-- | :-- | :-- | - | protocol (`api`) | the user's route-level `api` | the catalog spec's SDK | pi's built-in entry, then the id's family (`protocolFromModelId`, moved to `utils/protocols.ts`) | `openai-completions`, which both planes serve for every family | - | `baseUrl` | the user's route-level `baseURL` | the `endpoint` family for that protocol: the per-protocol `inference` bases by default (what 1.4.1 already sends); pi 1.1.0's legacy bases when the setting says so | | | - | name, context, max tokens, modalities, cost | the user's declared row | the catalog spec | pi's built-in entry | pi `Model` defaults | - | reasoning levels | the user's `reasoningEfforts` | `utils/model-metadata.ts` `reasoningFor(spec)` (D1: toggle → Off/On) | pi's built-in entry | none | - - - An id with no catalog spec, no pi entry and no declared row still gets a model (completions, defaults), so it lists, validates and dispatches. The gateway decides whether it exists, as it would for opencode itself. - - With no `models` list declared, the route serves the plane's whole servable catalog, as pi's catalog route does today. - - The user's route-level `apiKeyEnv` and `headers` are honoured, and the key resolves through the plugin's credential resolver (P6). -- **Deleted:** - - the llm-pi-ai child mount and its `llmFacade` Proxy; - - every hidden route and its tables (`responses-routes.ts`, the hidden ids in `DEFAULT_PROVIDERS`); - - the listing hide (`listProviders`, `listConfigurableProviders`, `listModels`); - - the stream redirect and `isAvailable`; - - the adapter patch on a host-owned adapter (`decoratePlaneAdapters`), because the route's adapter is now the plugin's own; - - the Gemini bridge registration, which becomes ordinary models on the plugin's provider; - - the Google history and replay translation, if replay stays on one provider label (verify by test); - - `internalRoutes` (D2). -- **What remains private:** one seam, the `ResolvedPiAiProviderProfile` shape handed to the public `PiAiAdapter` (already used today, by the Gemini bridge). A contract test against the real `@deepseek-ai/dsh-llm-pi-ai` pins it, so a host upgrade that changes it fails CI rather than a user's turn. -- **What the user sees:** the same two providers in the composer and in Settings, under the same names. They now serve every protocol, every catalog model and any custom id, on the plugin's endpoints. Remove the plugin, and pi-ai serves them again from the same file. +#### `src/host/route-claim.ts`: pi-ai never claims the planes + +- **Where the listener lives.** The plugin root declares `inject: []`; today it is `["llm"]`. The `internal/config` listener (`prepend: true`) and the stream and fetch hooks live on the root, and everything that needs `llm` runs inside `ctx.inject(["llm"], scope => …)`. + - pi-ai is identified by `this.runtime?.name === "llm-pi-ai"`, never by `this.name`, which pi-ai's inject children inherit. + - Its fibers are found through `ctx.registry` runtimes. +- **What the listener does.** It is a pure filter and never mutates its input: + 1. It strips the two plane keys from a fresh copy. + 2. It runs the nested waterfall on the copy and returns that result. + 3. On a commit (`raw === this._config`, F14), it schedules `reconcile` with `queueMicrotask`. + 4. On an editor candidate, it strictly validates the plane entries that changed. A malformed save is rejected in pi-ai's message style. + + It never throws on a commit run, because a throw there would fail pi-ai's fiber and every route it holds. + +- **What `reconcile` does.** It reads the committed plane entries from `piFiber._config.providers`, memoized by object identity plus a JSON comparison. Then: + 1. It interpolates `{__jsExpr}` nodes with the loader's exported `interpolate` (F16). Without a loader, a node left over becomes a per-model `INVALID_CONFIG`. + 2. It normalizes the entries with pi-ai's exported `Config`, and builds the profiles. + 3. It reconciles the route set: + - with no handle and at least one plane, it calls `registerAdapter(routes)`; + - with a handle whose route set, `displayName` or `retryPolicy` changed, it calls `handle.replace(routes)`. `replace([])` is legal; `registerAdapter([])` is not. + + Only the planes the user declared are registered. `reconcile` also runs on a global `llm/adapters-updated`, and on `loader/partial-dispose` for pi-ai. + +- **Mount.** It runs inside apply's returned promise, never from a timer: + 1. Register the teardown effect first. + 2. Install the listener. + 3. If pi-ai holds a plane, `await piFiber.await()`; a FAILED pi-ai is tolerated. Restart pi-ai only if it is ACTIVE and its resolved config still holds a plane, then check again. + 4. `registerAdapter` is wrapped in try/catch. On `DUPLICATE_ADAPTER` it logs and retries on the next `llm/adapters-updated`, after `piFiber.await()`. +- **Teardown.** A single effect, registered first so that it runs last: + 1. It calls the handle, then sets the listener inert, with no await between the two. + 2. Only on a real dispose (`ctx.fiber.uid === null`), with pi-ai ACTIVE and its parent not unloading, it awaits pi-ai, restarts it so it takes the routes back, and checks the result through `listProviders()`. + + Reloading the plugin's own configuration restarts pi-ai zero times. + +#### `src/host/plane-provider.ts`, `src/utils/plane-profile.ts` and `src/utils/plane-model.ts` + +- **Where the classes come from.** `PiAiAdapter` and `Config` come from the host's llm-pi-ai module, through the existing `loadPiAiModule`. The host's pi comes through `loadHostPi`. Both lookups sit behind one function in `cordis-context.ts`, which the harness can replace. The registry and `adapter.config` are never read. +- **`class PlaneAdapter extends PiAiAdapter`.** It overrides `listModels`: when `super` answers `[]` or throws `MISSING_CREDENTIAL` (F18), it answers with the declared ids. Dispatch still fails loudly. +- **Auth (F19).** + - **Credentials.** A credential store over `ctx.credentials`, keyed by the exported `recordKeyFor(route)` and using pi-ai's record mapping, so a host Sign-in record under `llm-pi-ai/opencode` keeps working. + - **Environment.** `authContext.env(name)` reads the credentials service first, then the launch environment. + - **Provider auth.** The composite provider's `auth` is pi's own `opencodeProvider().auth` / `opencodeGoProvider().auth`. + - **Keys.** `resolveApiKey` follows D20. +- **`utils/plane-profile.ts` is pure and never throws.** It builds a `ResolvedPiAiProviderProfile` from a raw entry. Every field comes from the entry or the plugin's constants, and none is copied from a sibling profile. It sets: + - `Config`'s defaults and every `PiAiProviderProfile` field; + - `configuredMaxTokens`; + - `retryPolicy`, through dsh-llm's `resolveRetryPolicy`; + - `modelErrors` and `catalogError`, from a mirror of pi-ai's semantic checks minus "needs an api" (D15); + - `piProvider`, built with `createProvider({ id, auth, api, models })`. `api` maps all four protocols, each wrapped in `withOpenCodeSessionHeader(Api())`, from the host pi's public exports. +- **`utils/plane-model.ts` `planeModelFor` is pure.** Each field takes the first source that has it: + + | field | rule | + | :-- | :-- | + | protocol | catalog SDK (`PROTOCOL_FOR_SDK`, with `@ai-sdk/mistral` → completions as pi's own data has it) > pi's entry > the user's route-level `api` (only for ids neither classifies) > `openai-completions` (D16) | + | name, context, max tokens, modalities, cost | the user's row > pi-ai's route-level `modelOverrides` > catalog > pi > defaults (D17) | + | reasoning levels | the user's `reasoningEfforts` > `reasoningFor(spec)` from the catalog (D1: a toggle model offers Off/On, in both vocabularies) > pi > none | + | baseUrl | the route-level `baseURL` > the `endpoint` family table | + + The baseUrl row resolves as follows: + - A route-level `baseURL` on a vendor root (`/zen…`, `/inference…/openai/v1`, `/inference(/go)/v1`) selects that family. + - A foreign root is used as written, except that a trailing `/v1` is stripped for anthropic-messages, because pi's Anthropic SDK appends `/v1/messages`. + - The family table defaults to `modern`; `auto` means `modern`. + - `legacy` takes the base from pi's own entry, with the fixed table as the fallback. Go has no legacy Google base, so that cell uses `OPENCODE_FAMILY_API.go["@ai-sdk/google"]`. + +- **`src/utils/protocols.ts`**, created in P-R1, holds the SDK constants, `PROTOCOL_FOR_SDK` and `isServableSdk`. `UNSERVED_*` goes. + +#### `src/host/patch-method.ts`: the two host wrappers that remain + +`patch-method.ts` restores a wrapper only if it is still the current one, deletes it when the original was inherited, and re-wraps on `llm/adapters-updated`. It installs two wrappers: + +- **`settings.describe` (D18, F20).** It runs inside `ctx.inject(["settings"])`, and touches only the `llm-pi-ai` descriptor. When `value.providers[plane]` is missing, it sets it to the interpolated, `Config`-normalized committed entry; `base`, `user` and the revision are untouched. A contract test pins that no node under `providers` in pi-ai's `Config` carries `role("secret")`. Today only `apiKeyEnv` has a role, and it is `credential-ref`. +- **`discoverModels` (P3).** It answers pi's rows plus the catalog's missing ones, deduped and whitelisted. A `NO_DISCOVERY` while pi-ai restarts yields catalog-only rows. + +#### Deleted + +- the llm-pi-ai child mount and its `llmFacade`; +- `responses-routes.ts`, and the hidden ids in `DEFAULT_PROVIDERS` and `cordis.patch.yml`; +- the listing hide; +- the stream redirect and `isAvailable`; +- `decoratePlaneAdapters`, and the plane overrides in `decorateModelInfo`; +- the Gemini bridge registration; +- the Google replay translators, if replay stays on one provider label (a test decides); +- `protocolFromModelId`, `adapter-replacement.ts`, and the D19 settings. + +#### Removing the plugin + +pi-ai serves the models it knows again, from the same file. Ids pi can't serve drop out of the composer, and the next Settings edit of that provider is refused until they are removed. Three things cover this: + +- a README section; +- a handback test that pins the behaviour; +- a card action that lists those ids and removes them (P10b). ### Measured on the real host -Runs A–D: published `@deepseek-ai/dsh` 0.2.1-rc.2 with the pi 1.4.0 it installs, offline, with an instrumented fetch. Run S0 swaps pi to **1.1.0 plus the harness's own patch** (D14). The prototypes are throwaway, and not in the repository. +**Runs.** + +- Runs A–D use the published `@deepseek-ai/dsh` 0.2.1-rc.2 with the pi 1.4.0 it installs, offline, with an instrumented fetch. +- Runs S0 and S0b swap pi for **1.1.0 plus the harness's own patch** (D14). +- The prototypes are throwaway, and are not in the repository. | Run | Registered | Known models, 4 shapes × 2 planes | A model pi lacks | Saving a model pi lacks | | :-- | :-- | :-- | :-- | :-- | -| A: plugin 1.4.1 as shipped | `opencode`, `opencode-go` + 6 hidden routes | served, through the hidden routes, on the plugin's per-shape endpoints | **not listed, `INVALID_CONFIG`, never sent** | **rejected** ("needs an api") | +| 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: pi 1.1.0 + route claim + plugin provider** | `opencode`, `opencode-go` → **the plugin's adapter**; `deepseek`, `deepseek-official`, `deepseek-account` → pi-ai, 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`, with the same paths under `/inference/go/` on Go, **Gemini on Go included** | Zen `gpt-5.3-codex-spark` → `/responses` (id family), 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, then it is listed, validates and is sent as completions | +| **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 | -Two more S0 checks: +**What the runs did not settle.** -- **Handback.** With the listener removed, restarting pi-ai gives it both routes back. It then lists what pi 1.1.0 knows, and the file is untouched. -- **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, not this design. +- **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. -Left for P-R1's tests: +**Fallback, only if the P-T1b harness contradicts S0:** run C's shape. That shape has three parts: -- the brief window at startup while pi-ai restarts (its other routes re-register with it); -- whether the gateway serves both endpoint families. That can't be measured from a sandbox that blocks `opencode.ai`, so P-R2 measures it in CI's live e2e job: a keyless request is enough, because 401 means the path exists and 404 means it doesn't. - -**Fallback, only if P-T1's harness contradicts S0:** run C's shape. That means an unregistered catalog adapter per plane serving the gap models; an adapter patch on pi-ai's route adapter delegating them; and a non-mutating save gate that validates a copy without the unresolvable ids, then absorbs only pi-ai's "needs an api" refusal for those ids on those two routes. +- 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 @@ -220,177 +288,323 @@ src/ index.ts apply, Config, inject, name + config types (D6) lifecycle.ts apply(): installers only host/ - patch-method.ts patch a host method: restore only if still current; delete when the original - was inherited; read "unpatched" from the prototype; re-wrap on llm/adapters-updated + 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 listener: pi-ai resolves its config without the - two plane providers, so it never claims them; restarts pi-ai on mount/dispose - plane-provider.ts the plugin's PiAiAdapter, registered as opencode + opencode-go, built from - the user's entries with a composite pi provider and the plugin's endpoints + 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 (one pi Model per declared id, F9 precedence), - model-metadata (reasoning, both vocabularies), credential-policy, - request-shape, session-id, google-replay, catalog-parse + 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 ``` -There are no hidden routes, no listing hide, no stream redirect, no child mount, no Gemini bridge registration, no patched host adapter and no `resolveModelInfo` override (D8). One private seam is left, the `ResolvedPiAiProviderProfile` shape handed to the public `PiAiAdapter`, and a contract test against the real host packages pins it. +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 -One PR each, from `main`, after P0 merges. The first commit of each phase is a test that fails on `main`. +**How each phase opens:** + +- **Behaviour phases open with a red test:** P1a, P1b's fixes, P2, P-R1, P-R2, P3, P6 and P10b. +- **Pinning phases open with a green pin**, which the PR shows unchanged: P-T1a/b, P-H, P5, P7, P8, P9, P10a and P-T2. +- **P-T3 and P12 are exempt.** + +**Characterization rows are tagged when they are recorded**, so a later phase flips named rows: + +- `KNOWN_DEFECT`: + - Go Gemini answers `INVALID_CONFIG`; + - an unknown id is rejected; + - toggle models show Minimal/Xhigh/Max. +- `INTENDED_CHANGE`: + - completions models move from `/zen(/go)/v1` to `/inference(/go)/openai/v1`; + - `decoratePlaneAdapters`' baseUrl repair never reaches the wire. 1. **P1a `fix:` shipped breakage.** - - Drop `export { default }` from the wrapper. The import test resolves `dsh-opencode-patch` from the dry-run scratch directory. - - `isZenProvider`. - - Status pass-through in `enrichModelsResponse`, keeping the Go 404 fill. + - 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`. - - Spinner keyframes in the settings styles. + - The spinner keyframes are in the settings styles. - One debug gate. - - `settings-purge` uses `parsePurgeResult` and reads the envelope. + - `settings-purge` uses `parsePurgeResult`. + - The endpoint copy (`config.ts`, `config-values.ts`, README) names the per-protocol inference family for `modern`, with `auto` as its alias. 2. **P1b `chore:` hygiene.** - - Delete `adapter-replacement.ts` and its test, which nothing imports. Extend `docs/engineering-notes.md`'s plane-route override section (≈`:535-549`) with why the approach is unsupported. - - Delete the dead `prepared` code, and `.freebuff/`. - - The partition test walks `Config.dict`, and a manifest test checks `cordis.patch.yml` against `Config.dict`. - - `retry.ts` takes its timer and logger as parameters; the architecture test bans `console`/`setTimeout`/`setInterval` in `utils/` and walks recursively. - - `tool-fallback` dead branches and plane lookup. - - Replace `docs/improvement-plan.md` with this plan (D10). -3. **P2 `feat!:` the major.** - - `index.ts` → `apply`, `Config`, `inject`, `name` and the config types. - - All 30 importing test files (including e2e and mock) go through one `test/support/src.ts`, so later moves touch one file. - - Delete `resolveRoutedKey` (D4) and `internalRoutes` (D2; breaking for anyone who set it to false). Retarget P0's bridge-disposer test to the "every route declared" exit (P-R1 later deletes the bridge, and the test with it). - - Stop publishing `@viztor/dsh-opencode` (D9) and set its final deprecation message. - - Document the D1 effort break here, so every breaking change ships in one 2.0.0. The `BREAKING CHANGE:` footer lists all of them. -4. **P-T1 `test:` a harness that is the real host.** - - Pin the dev dependency `@earendil-works/pi-ai` to **1.1.0** (D14), with a test that fails if it drifts from the harness's lockfile pin. - - Build `test/support/host.ts` on the **real** packages already in `node_modules`: a cordis `Context`, `@deepseek-ai/dsh-llm`'s `LlmRuntime` and `@deepseek-ai/dsh-llm-pi-ai`'s `apply`. It is pointed at the mock gateway (`test/mock/`) through `fetch`, with a profile builder for the deployment shapes that matter: no `models` list, a `models` list of known ids, gap ids in the config file, and a route-level `baseURL`/`api`. - - **Characterization tests** that record today's behaviour across those shapes: for each model, the endpoint and protocol it reaches, and what the composer (`buildModelCatalog`'s logic), validation (`resolveCallConfig`) and Settings (`discoverModels`) report. They are the safety net for P-R1, and the measurement behind G1–G3. - - **Contract tests** against the real packages, so an upgrade breaks CI, not a user. They pin: - - the profile shape; - - F11, that pi-ai's routes are the keys of its resolved config; - - F12, that the waterfall's return value is what the fiber keeps, and that the editor writes the raw value. - - Replay run S0 as tests on this harness. Its result confirms the design for P-R1, or sends it to the fallback. - - Retire the ~60 ad-hoc contexts (21 with no-op `effect`), `createMockContext` and `HarnessSimulator`. - - Add `host/patch-method.ts` (restore only if still current; delete when inherited; re-wrap on `llm/adapters-updated`), and `test/reload.test.ts` (apply A → apply B → dispose A → B intact → dispose B → originals) for the sites that remain. -5. **P-R1 `refactor:` the plugin owns the plane routes (D5, D13).** The core of the refactor; the steps are in "Routing". - - `host/route-claim.ts`, `host/plane-provider.ts` and `utils/plane-model.ts`. Tests run against the real llm-pi-ai, llm and cordis: - - after mount, `opencode`/`opencode-go` are the plugin's adapter, and every other pi-ai route is unchanged; - - a known model, a pi-1.1.0 gap model and **a catalog-unknown id** each save through `configEditor.edit` (the file holds exactly what was saved), list, validate and dispatch (D15); - - a broken entry on any other pi-ai provider is still rejected; - - the listener never writes to the user's config object; - - mount while pi-ai is active, dispose, and remount: the routes move to the plugin, back to pi-ai, and to the plugin again, with no `DUPLICATE_ADAPTER`. - - Delete the hidden-route machinery: the child mount and its facade Proxy, `responses-routes.ts`, the listing hide, the stream redirect and `isAvailable`, `decoratePlaneAdapters`, the Gemini bridge registration, the hidden ids in `DEFAULT_PROVIDERS` and `cordis.patch.yml`, and the tests that pin them. - - The Google replay translators go too, if the characterization tests show replay stays on one provider label. - - **The P-T1 characterization tests must pass unchanged**, except the rows that recorded a defect, each named in the PR. - - **New tests:** - - every model on each plane, in each protocol, reaches its endpoint in the chosen family, **Go Gemini included** (F10); - - `planeModelFor` table tests: each field's precedence (route-level override > declared row > catalog > pi > defaults; SDK > pi > id family > completions; both endpoint families); - - a user's row (name, context, max tokens, modalities, efforts) wins end to end, in picker and dispatch. - - Expected size: a net deletion of well over a thousand lines. -6. **P-R2 `fix:` the endpoint family, measured (G2).** - - Measure, with the opt-in live e2e, which family the gateway serves for each protocol on each plane: the `inference` bases and pi 1.1.0's legacy ones (`/zen`, `/zen/v1`, `/zen/go`, `/zen/go/v1`). - - Set the measured default, and pin it with tests. -7. **P3 `feat:` Settings complements pi's list (D3, D8).** - - The `discoverModels` wrapper returns pi's answer plus the plane's **servable, non-deprecated** catalog models missing from it. It dedupes, and whitelists to `LlmDiscoveredModel`'s fields. - - Gap rows save like any other (D15), and the composer then shows them, served by the plugin's provider. - - The composer's `listModels` stays declared-only. - - Delete `installModelDiscovery`/`toDiscovered` and P0's two discovery-reload tests. - - Rewrite the "adds nothing" tests (`test/models-discovery.test.ts:207-210,307-319`) and the contradicting docs (`README.md:220`, `deep-dive.md:14,174-175`, `redirect-planes.md:102`). `deep-dive.md:14`'s "one wire protocol per route" premise is false for this host (F1). -8. **P4 `feat:` follow the user's configuration; on/off for gap models (D1, D8).** - - Remove the catalog fill for models pi knows: the plane-route overrides in `decorateModelInfo`, and the same-mode fill inside the current adapter patch. The user's row, or pi, answers. - - For models with no user row, `utils/model-metadata.ts` `reasoningFor(spec)` builds the plugin provider's levels in both vocabularies. A toggle model offers **Off/On**; old Minimal/Xhigh/Max selections break (documented in P2). - - Delete the duplicate `ALL_THINKING_LEVELS` and the three capitalisation copies. - - **Tests:** for every gap model on each plane, picker efforts = the levels dispatch accepts; for a known model, both equal pi's or the user's row; the "on" wire value is verified per protocol with an e2e probe. -9. **P-T2 `test:` suite structure.** - - Move the 9 mock-booting unit files into `test/mock/`, de-duplicated against `request-shapes.mock.ts`, and the 3 offline e2e files into mock. - - Delete the self-testing harness and mock-gateway tests. - - Turn constants into invariants. - - Split the 7 big test files. - - One shared e2e environment helper; remove duplicated helpers, device-login mock routes and the hard-coded `ses_`. - - Coverage tests for `session-id` and `opencode-store-contract`, plus per-file floors for `lifecycle`/`stream-hook`/`fetch-patch`. -10. **P-T3 `ci:` CI and scripts.** - -- One unit run (`test:coverage`), with build before tests that read `lib/`, and type-check `test/**/*.tsx`. -- One composite setup action; the catalog fork `if:` and the pnpm comments. -- Paid e2e runs only on schedule or dispatch. -- `check.ts` keeps lib freshness (once), the consumer install (loading the client half too) and the size tripwire; its pure file checks move to vitest. -- The generators share helpers and one staleness rule; `verify-*` scripts exit non-zero; `capture-ui.sh` has no machine paths; `no-local-paths` covers every published file plus `.github/` and `*.sh`. - -11. **P5 `refactor:` installers.** `installRemoteService(ctx, Service, contract, options)` replaces the three mount-once copies. Each reads the latest activation's settings and reports through `ctx.logger`. Update `scripts/check.ts:320-334`'s probe in the same PR. -12. **P6 `refactor:` one credential resolver.** - - Parity test over the six deciders. - - `utils/credential-policy.ts` + `credentials.ts`; the placeholder filter applied once. - - The plugin provider's key resolution is the same resolver, store fallback included. - - `CaptureStore` per root, evicting a key on 401. - - No `process.env` in `fetch-patch`, `lifecycle` or the plugin provider. -13. **P7 `refactor:` one source per table.** - - `utils/protocols.ts`: SDKs, one protocol map and one plane table (with the hidden routes gone after P-R1, the per-route tables go too), and one `planeForRoute` (four copies today). - - One `formatUsd`. - - One `specFor(route, id)`. -14. **P8 `refactor:` settings table.** - - First, a snapshot test of the derived `Config`. - - Then `settings-table.ts` drives the schema (schemastery `union`/`const`/`loose`, checking that `loose` and `volatile` compose), the resolver, the types, the card, the copy and the manifest. - - Delete the 14 dead copy keys and `modelMarkers`; fix the zh `title`. -15. **P9 `refactor:` remote seam.** - - `defineRemote` contracts, `markRemote`, `callRemote` (one unwrap, parsed by the contract). - - Delete `isGoUsage` and the four hand-written unwraps; add the `opencodeGoUsage` constant. - - The parity test reads the contracts. -16. **P10a `refactor:` pure module moves, no behaviour change.** - - `models-discovery` → `host/*`. - - What is left of `responses-provider` and `google-bridge` after P-R1 → `host/plane-provider.ts`, with the loader lookups moved into `cordis-context.ts`. - - `go-discovery` → `credentials.ts`. - - `usage-ui` → css/time/state/copy; `parseFailure` → the usage contract. + - Delete `adapter-replacement.ts` and its test, with a note in the engineering notes on why the approach is unsupported. + - Delete the dead `prepared` handling and `.freebuff/`. + - The partition test walks `Config.dict`, and a manifest test checks `cordis.patch.yml` against it. + - `retry.ts` takes its timer and logger as parameters. The architecture test bans `console` and timers in `utils/`, and walks the folder recursively. + - Remove the dead branches in `tool-fallback`. + - Add the `test/support/src.ts` indirection. +3. **P2 `feat:` a smaller public API.** It ships as a 1.x minor. + - `index.ts` exports `apply`, `Config`, `inject`, `name` and the config types. + - `./src/*` goes from `exports`, and `check.ts` asserts that every exports target is inside `files`. + - Delete `resolveRoutedKey` and `internalRoutes`. + - Stop publishing the wrapper. `publish-scoped.ts`'s `SCOPED_TARGETS` and `release.yml`'s verify list change together, and `check.ts` asserts that they agree. +4. **P-T1a `build:` pin the host.** + - `pnpm-workspace.yaml` gains `overrides: { "@earendil-works/pi-ai": "1.1.0" }` and `patchedDependencies`, with a committed copy of the harness's patch. The lockfile is regenerated. + - The `@deepseek-ai/*` packages move to rc.2, and the devDependencies gain `dsh-llm`, `dsh-app-boot`, `dsh-config-editor`, `dsh-settings` and `cordis-plugin-loader`. + - Add `test/support/host-versions.ts`, with a version test: `loadHostPi().version` is 1.1.0, and the root pi is the same copy. + - Rewrite `mount-protocols.test.ts` as "every catalog SDK maps to a protocol that pi's `api/*` exports implement", keeping the row that pi 1.1.0's Go provider lacks Google. +5. **P-T1b `test:` the real-host harness.** It is additive only. `test/support/host.ts` provides two tiers that run inside the test process: + - **Tier A:** a cordis `Context`, `LlmRuntime`, and pi-ai mounted with `{ providers }`. + - **Tier B:** dsh-app-boot's `boot()` with a temporary HOME, and a test bundle that names the loader builtins `llm`, `llm-pi-ai`, `ConfigEditor`, `Settings` and the plugin. It follows the harness's `experimental/speech-to-text/tests/configuration.spec.ts:7-33`, with a fake `credentials` service and a launch-environment snapshot. + + This phase lands: + - the contract tests; + - the tagged characterization rows; + - the S0 assertions, including `settings.describe`, `hasProviderApiKey` and the Models store's join; + - the CI additions below. + +6. **P-H `refactor:` one way to patch a host method.** `host/patch-method.ts` and `test/reload.test.ts` (apply A → apply B → dispose A → B intact → dispose B → originals). +7. **P-R1 `feat:` the plugin owns the plane routes.** + - **New:** + - the design above; + - D1, D16, D17, D19 and D20; + - the `settings.describe` wrapper; + - `utils/protocols.ts`. + - **Deleted:** the hidden-route machinery and the D19 settings. + - **Tests:** + - retarget `protocol-routing.e2e.ts` and `dispatch-levels.e2e.ts` to the planes; + - move the per-file coverage floors to the new modules, at 100 for the pure ones, and check that every floor's glob matches a file. + - **Docs:** + - the README, including "Removing or disabling the plugin" and "Changed in 1.x"; + - `docs/redirect-planes.md` §8-9 and `docs/protocol-routing-and-merge.md`; + - AGENTS.md's repo map and CONTRIBUTING.md. + - **The PR body** names every tagged row it flips. +8. **P-R2 `fix:` the endpoint family, measured.** + - Probe 3 URL shapes × 4 protocols × 2 planes, keyless, plus keyed probes per family with the CI keys. + - Set the default family and pin it with a test. If a `modern` cell fails, flip the default; `endpoint: legacy` still maps every protocol. +9. **P3 `feat:` Settings complements pi's list.** + - The `discoverModels` wrapper answers servable, non-deprecated models, deduped and whitelisted. + - Delete `installModelDiscovery` and `toDiscovered`. + - Rewrite the "adds nothing" tests, and the docs that say the same. +10. **P-T2 `test:` suite structure.** + - Move the mock-booting unit files into `test/mock/`. + - Retire the ad-hoc contexts, `createMockContext` and `HarnessSimulator`. + - Turn constants into invariants, and split the seven big files. + - One shared e2e environment helper. + - Cover `session-id` and `opencode-store-contract`. +11. **P-T3 `ci:` CI and scripts.** + - **The test run:** one unit run; build before the tests that read `lib/`; type-check `test/**/*.tsx`. + - **Setup:** one composite setup action. + - **`check.ts`** keeps the lib freshness check, the consumer install and the size tripwire. + - **Scripts:** the generators share helpers, and the `verify-*` scripts exit non-zero. + - **`no-local-paths`** also covers `.github/` and `*.sh`. +12. **P5 `refactor:` installers.** `installRemoteService` replaces the three mount-once copies. +13. **P6 `refactor:` the fetch patch's credential fill.** The adapter path is already D20. + - One resolver, for an empty or placeholder wire key only. + - A parity test over the deciders, with the adapter path as its own row: a rotated key takes effect, and a service failure propagates. + - A `CaptureStore` per root, evicting a key on 401. +14. **P7 `refactor:` one source per table.** One `planeForRoute` (four copies today), `formatUsd` and `specFor`; delete the duplicate `ALL_THINKING_LEVELS` and the capitalisation copies. +15. **P8 `refactor:` settings table.** It starts with a snapshot test of the derived `Config`. Then one field table drives the schema, resolver, types, card, copy and manifest. +16. **P9 `refactor:` remote seam.** `defineRemote`, `markRemote` and `callRemote`, with the parity test reading the contracts. +17. **P10a `refactor:` pure module moves.** + - `models-discovery` → `host/*`; + - `go-discovery` → `credentials.ts`; + - split `usage-ui`; - `fetch-patch` → `request/`. -17. **P10b `fix:` catalog state.** - - The parse is pure and returns `deprecated`; refresh replaces the maps. - - The refresh promise returns `{ ok, deprecated }` for the purge. - - The shim carries deprecated ids, and the staleness check compares the whole output. - - The purge makes one edit per entry and skips no-op entries. -18. **P11 `refactor:` UI on the primitives.** - - `SegmentedControl`, `Button` with `icon`, `useDismissOnOutsidePointer`, one `ActionRow`. - - Theme tokens only; inline styles moved to CSS. - - Re-measure the bundle budget. -19. **P12 `ci:`** make lint warnings gating once `src/` is at zero. +18. **P10b `fix:` catalog state.** + - The parse is pure and returns `deprecated`, and a refresh replaces the maps. + - The purge makes one edit per entry. + - The card lists the ids pi can't serve, and removes them. +19. **P11 `refactor:` UI on the primitives.** +20. **P12 `ci:`** make lint warnings gating once `src/` is at zero. ## Order ``` -P0 → P1a → P1b → P2 (2.0.0) → P-T1 → P-R1 → P-R2 → P3 → P4 → P-T2 → P-T3 → P5 → P6 → P7 → P8 → P9 → P10a → P10b → P11 → P12 +P0 → P1a → P1b → P2 → P-T1a → P-T1b → P-H → P-R1 → P-R2 → P3 → P-T2 → P-T3 → P5 → P6 → P7 → P8 → P9 → P10a → P10b → P11 → P12 ``` -- **P1a first:** shipped breakage. -- **P2 bundles every breaking change into one major:** D2, D4, D6, D9 and the D1 note. -- **P-T1 before any routing change:** the real-host harness records today's behaviour, which P-R1 must keep. -- **P-R1 is the refactor that matters:** it removes the workaround's moving parts, and everything after it is smaller because of it. -- **P10a only after P5–P9:** a move is safe once tests pin what moves. +- **P1a first:** it fixes what already shipped broken. +- **P-T1a and P-T1b before any routing change:** the real-host harness records today's behaviour, and P-R1 must keep it except for the rows it names. +- **P-H before P-R1:** P-R1 needs the `describe` wrapper. +- **P10a only after P5–P9:** a module is moved only once tests pin what it does. + +## Test matrix + +**P1a–P2** + +- **Unit:** + - each shipped breakage, red before its fix; + - the wrapper forwarder; + - a snapshot of the endpoint copy; + - the manifest is a subset of `Config.dict`; + - the `src.ts` indirection; + - the public API, pinned green and then shrunk; + - `exports` targets are inside `files`; + - the scoped publish list matches `release.yml`'s; + - the deleted settings are gone everywhere. + +**P-T1a** + +- **Unit:** + - the version pin, and a same-copy check on the realpath; + - the rewritten `mount-protocols` test. + +**P-T1b** + +- **Real host, tier A:** `listProviders()` equals pi-ai's keys (F11). +- **Contracts:** + - the listener fires for another plugin's fiber, and `prepend` puts it outermost; + - `raw === _config` holds for `_reload`, `update` and `_commitVolatile`, and not for the editor's candidate (F14); + - a deep-frozen input is never mutated, and the fiber keeps the return value (F12); + - the profile shape, the `Config` defaults, the credential record mapping and `recordKeyFor`; + - no secret-role nodes; + - the loader's `interpolate`; + - pi's public exports. +- **Real host, tier B:** + - `configEditor.edit` writes the raw `next` to the YAML; + - `_commitVolatile` re-resolves through a prepended listener; + - baselines for `settings.describe` and `hasProviderApiKey`. +- **Characterization rows**, tagged, on pi 1.1.0. + +**P-R1** + +- **Unit:** + - `planeModelFor`, one row per field × known or gap model × `modern` or `legacy` × vendor or foreign base, including the Go Google legacy cell and `auto` meaning `modern`; + - `plane-profile`: + - its field mapping and defaults; + - a malformed-entry table: wrong types, an empty or duplicate id, an invalid header, a `null` effort on anything but off, removed keys; + - it never throws; + - `!!js` is interpolated first; + - `withoutPlaneProviders` makes a fresh copy and never touches a frozen input; + - the `protocols.ts` tripwire. +- **Real host, tiers A and B:** + - **Ownership and restart counts:** + - pi-ai first: one restart; + - plugin first: none; + - both registry orders in one batch, with no `DUPLICATE_ADAPTER` escaping; + - pi-ai FAILED: nothing claimed; + - `listProviders()` restored after every transition. + - **Lifecycle:** + - dispose, then apply again; + - a config reload restarts pi-ai zero times; + - an `llm` restart; + - pi-ai restarting itself while claimed; + - a stream in flight completes; + - after dispose, the routes are handed back, and the next Settings edit of `opencode` is refused. + - **Config flow:** + - a save rejected by a home patch or by a sibling provider changes nothing; + - a plane-only save is served without a restart; + - a `displayName` edit goes through `replace`; + - a purge edit is accepted; + - a malformed candidate is refused; + - a malformed stored entry leaves `deepseek` serving, with one `INVALID_CONFIG`. + - **Route set:** + - one plane only; + - the first plane added at runtime; + - the last plane removed, through `replace([])`; + - a plane re-added; + - booting with none. + - **Credentials:** + - the credentials service's environment and a Sign-in record both authenticate; + - a missing named ref still lists the models, and dispatch fails with `MISSING_CREDENTIAL` without a request; + - a service failure propagates; + - a rotated key takes effect; + - captured keys are never used. + - **Isolation:** a sibling's headers, base, api, reasoning and retry never leak into a plane. +- **Contract:** + - the `describe` graft deep-equals the file's entries; + - the Models store's join shows the planes as configured, and a key typed in lands under the user's `apiKeyEnv`; + - `hasProviderApiKey`; + - parity with pi-ai's real `apply` on a route id that is not a plane. +- **Mock gateway:** `llm.stream` through the real `LlmRuntime` for every model × plane × protocol, including Go Gemini and a catalog-unknown id. It checks: + - the path, the body shape, the credential header and `x-opencode-session`; + - the `legacy` family; + - the shaping of a route-level `baseURL`. +- **Binary e2e, offline:** the S0 matrix. +- **Live e2e:** + - the retargeted `protocol-routing` and `dispatch-levels` suites; + - the "on" wire value per protocol for one toggle model on each plane. + +**P-R2** + +- **Live e2e:** the keyless matrix, keyed probes per family, and one keyless free-model probe. +- **Unit:** the default family. + +**P3** + +- **Unit:** + - the wrapper's merge; + - `NO_DISCOVERY`. +- **Real host:** + - discovery while pi-ai restarts; + - a composer with no `models` list. +- **Contract:** the wrapper is seen through the gateway and survives a restart. +- **Binary e2e:** the Settings fetch action. + +## End-to-end infrastructure + +### The `e2e-host` job (`ci.yml`) + +**When it runs:** on dispatch, nightly, and on PRs that touch `src/host/**`, `src/lifecycle.ts`, `src/identity.ts` or `cordis.patch.yml`. + +**What it does:** + +1. Installs `@deepseek-ai/dsh@$DSH_VERSION` into a throwaway prefix, cached by host version, with npm `overrides` forcing pi. +2. Asserts the pi version that resolved. +3. Builds, and boots a temporary HOME whose profile links the checkout through a relative `link:`. +4. Runs `test/e2e/host/probe-plugin.mjs` together with `test/e2e/host-binary.e2e.ts`, which runs only when `DSH_HOST_E2E=1`. + +**Cells:** + +- (a) pi 1.1.0, pinned and patched, on PRs; +- (b) whatever npm installs, nightly and on dispatch. + +### The live gateway job (`OPENCODE_API_KEY` / `OPENCODE_GO_API_KEY`) + +- **Every PR:** the keyless matrix. A 404 means the path is gone; a 400 "Model is unavailable" or a 410 means the model is gone. +- **PRs that touch `src/host/**`, and nightly:** minimal keyed probes, one per plane × protocol, with a small `max_tokens`. +- **Nightly and on dispatch:** the full keyed suites. + +## How each phase is built + +1. **A spec**, written from this document and the code. It names the files, the first tests and their exact assertions, and the verification commands. +2. **The implementation.** The first test goes red on the base, or green for a pin. Then the change is made, and the gate below runs. +3. **A review panel**, running three reviews in parallel: + - correctness and the host's contracts; + - whether the tests perform the behaviour, plus coverage; + - the repository's rules, the docs and this plan. + + Two independent skeptics check each finding. Fixes repeat until no blocker or high finding survives. + +4. **The PR.** + - It is stacked on the previous phase, under a Conventional title. + - It names the rows it flips, and ticks its status row here. + - CI failures are fixed on that branch, then merged up the stack with a merge commit, never a rebase. ## Verification (every phase) Run on Node 26 (`.node-version`): - `pnpm run check`: 0 errors, and no more warnings than before; -- `pnpm run test`, `test:shuffle` on 3+ seeds, and `test:mock`; +- `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. - -Also: - -- The phase's first test fails on `main`. -- For P-R1, P3 and P4, P-T1's real-host harness shows: - - the composer is declared-only; - - Settings is pi's list plus the missing catalog models; - - a user-configured row's efforts and context reach both picker and dispatch unchanged; - - a gap toggle model offers Off/On; - - every model reaches its endpoint in its own protocol, on the user's route id, with no hidden route registered; - - a model id the catalog has never heard of saves through Settings' `configEditor` path and dispatches; - - all of it on pi 1.1.0. -- P4's e2e probe confirms the "on" wire value per protocol against the live gateway (opt-in job). -- Push, open the PR with a Conventional title, and watch CI to green before starting the next phase. +Before each commit: + +- grep the diff and `lib/` for local paths; +- check that no secret or key value appears in a log, fixture or snapshot. + +For P-R1 and P3, the binary e2e (both pi cells) and the live keyless matrix must be green. The PR body records: + +- the measured endpoints; +- the `describe` graft; +- the handback; +- the save of a catalog-unknown id. From 813e6f3bcdb64a64c58607cf17d321cf6384926d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:44:40 +0000 Subject: [PATCH 11/17] chore: refresh the generated catalog and limits tables The vendor added kimi-k3.1-pro to the Go plane, so `catalog freshness` went red on every branch of this stack. This takes the two generated files exactly as the nightly refresh produced them (#111), with no hand edits, and updates the counts that hard-code the catalog's size: the Go shim test (28 -> 29), the plane table in redirect-planes.md (Go served 28 -> 29, the no-SDK Go cell 17 -> 18), and the Go completions list in both READMEs. The listed half of "29 served / 33 listed" is models.dev's count, inferred from the one added entry, because models.dev cannot be reached from here. It no-ops once main carries #111. P-T2 turns these counts into invariants so a refresh stops needing this. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- README.md | 2 +- README.zh-CN.md | 2 +- docs/redirect-planes.md | 6 +++--- src/catalog-data.ts | 17 +++++++++++++++++ src/go-limits-data.ts | 7 ++++++- test/catalog.test.ts | 2 +- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 31d7f18c..d011a981 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ DSH speaks three of the four families itself. The fourth — Google Generative A - **Google Generative AI** (`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`): `gemini-4-argon-contributor`, served by the internal `opencode-go-google` adapter. Add the model under your existing `opencode-go` provider; the bridge inherits that provider's API key reference (default `OPENCODE_GO_API_KEY`). It supports image attachments and signed tool-call replay. Only configured models are mounted; access still depends on your Go plan. This endpoint is also used with `endpoint: legacy`, because Go has no legacy Google endpoint. -- **OpenAI Chat Completions** (`https://opencode.ai/zen/go/v1/chat/completions`): `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` +- **OpenAI Chat Completions** (`https://opencode.ai/zen/go/v1/chat/completions`): `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` - **OpenAI Responses** (`https://opencode.ai/zen/go/v1/responses`): `gpt-5.6-luna`, `gpt-6-luna`, `grok-4.5`, `grok-4.6`, `grok-4.7`, `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor` - **Anthropic Messages** (`https://opencode.ai/zen/go/v1/messages`): `claude-haiku-5-5`, `minimax-m2.7`, `minimax-m3`, `qwen3.7-plus`, `qwen3.8-flash`, `qwen3.8-max` - Monitored by the live 3-window quota meter (5-hour rolling, weekly, monthly). The full set ships in the bundled catalog — see [Authoritative Model Catalogs](#7-authoritative-model-catalogs-dual-local-shims--real-time-swr-updates). diff --git a/README.zh-CN.md b/README.zh-CN.md index dde83f11..d10d494a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -146,7 +146,7 @@ DSH 能说其中四种,插件就把这四种都路由掉。**Google Generative - **Google Generative AI**(`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`):`gemini-4-argon-contributor`,由内部适配器 `opencode-go-google` 服务。在现有 `opencode-go` 提供商下配置该模型即可,适配器继承该提供商的 API Key 引用(默认 `OPENCODE_GO_API_KEY`),支持图片附件和带签名的工具调用回放。只挂载已配置的模型,可用范围仍由 Go 套餐决定。Go 没有旧版 Google 接口,因此 `endpoint: legacy` 也使用此地址。 -- **OpenAI Chat Completions**(`https://opencode.ai/zen/go/v1/chat/completions`):`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` +- **OpenAI Chat Completions**(`https://opencode.ai/zen/go/v1/chat/completions`):`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` - **OpenAI Responses**(`https://opencode.ai/zen/go/v1/responses`):`gpt-5.6-luna`, `gpt-6-luna`, `grok-4.5`, `grok-4.6`, `grok-4.7`, `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor` - **Anthropic Messages**(`https://opencode.ai/zen/go/v1/messages`):`claude-haiku-5-5`, `minimax-m2.7`, `minimax-m3`, `qwen3.7-plus`, `qwen3.8-flash`, `qwen3.8-max` - 由实时三窗口额度计量监控(5 小时滚动、每周、每月)。完整集合随内置目录发布——参见[权威模型目录](#7-权威模型目录双本地预置--实时-swr-更新)。 diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 559471dd..9bb3cc14 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -11,7 +11,7 @@ A **plane** is one gateway base URL with its own catalog and its own credential. | Plane | Route id | Base URL | Credential | Models | | :-- | :-- | :-- | :-- | --: | | Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | `OPENCODE_API_KEY` | 82 served / 127 listed | -| Go (subscription) | `opencode-go` | the `endpoint` family's | `OPENCODE_GO_API_KEY` | 28 served / 32 listed | +| Go (subscription) | `opencode-go` | the `endpoint` family's | `OPENCODE_GO_API_KEY` | 29 served / 33 listed | **There is no longer one base URL per plane to quote.** The gateway answers on two endpoint families and the `endpoint` setting picks one: `auto` follows the catalog, which today names `…/inference/openai/v1` and `…/inference/go/openai/v1`, and `legacy` pins the older `…/zen/v1` and `…/zen/go/v1`. Both are still served and they are entitled separately, which is why the choice is the user's rather than a migration. The dated measurement below was taken on the legacy family. @@ -25,13 +25,13 @@ A **shape** is one wire protocol, named by the SDK a model declares in the catal | SDK in the catalog | Protocol | Zen | Go | | :------------------ | :---------------------- | --: | ----: | -| _(none)_ | `openai-completions` | 25 | 17 | +| _(none)_ | `openai-completions` | 25 | 18 | | `@ai-sdk/openai` | `openai-responses` | 31 | **5** | | `@ai-sdk/anthropic` | `anthropic-messages` | 17 | **5** | | `@ai-sdk/mistral` | `mistral-conversations` | 1 | — | | `@ai-sdk/google` | `google-generative-ai` | 8 | **1** | -**Absence is the signal, and it is a real one.** Both planes set a provider-level `npm` of `@ai-sdk/openai-compatible` in `models.dev`, and **no model declares it explicitly**: 0 of 82 on Zen, 0 of 28 on Go. A model that speaks the completions shape simply omits `provider.npm` and inherits it. So "no SDK" does not mean "unknown"; it means _the provider default_, which is the protocol the route was itself registered with. +**Absence is the signal, and it is a real one.** Both planes set a provider-level `npm` of `@ai-sdk/openai-compatible` in `models.dev`, and **no model declares it explicitly**: 0 of 82 on Zen, 0 of 29 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. diff --git a/src/catalog-data.ts b/src/catalog-data.ts index bccbc339..a140dfb2 100644 --- a/src/catalog-data.ts +++ b/src/catalog-data.ts @@ -340,6 +340,7 @@ export const OPENCODE_GO_CATALOG: readonly CatalogModelSpec[] = [ context_window: 1_048_576, cost: { cache_read: 0.3, + cache_write: 3, input: 3, output: 15, }, @@ -350,6 +351,21 @@ export const OPENCODE_GO_CATALOG: readonly CatalogModelSpec[] = [ reasoning: true, reasoning_options: [{ type: "effort", values: ["max"] }], }, + { + context_window: 1_048_576, + cost: { + cache_read: 0.2, + cache_write: 2, + input: 2, + output: 10, + }, + id: "kimi-k3.1-pro", + input_modalities: ["text", "image"], + max_output_tokens: 131_072, + name: "Kimi K3.1 Pro", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], + }, { context_window: 1_000_000, cost: { @@ -1670,6 +1686,7 @@ export const OPENCODE_ZEN_CATALOG: readonly CatalogModelSpec[] = [ context_window: 1_048_576, cost: { cache_read: 0.3, + cache_write: 3, input: 3, output: 15, }, diff --git a/src/go-limits-data.ts b/src/go-limits-data.ts index ba6f7339..d71e0343 100644 --- a/src/go-limits-data.ts +++ b/src/go-limits-data.ts @@ -6,7 +6,7 @@ * OpenCode's own Go documentation; `pnpm run limits:shim` reports staleness and * `--write` rewrites this file. * - * 27 models. + * 28 models. */ import type { ModelAllowance } from "./go-limits.ts"; @@ -82,6 +82,11 @@ export const GO_MODEL_LIMITS: Readonly> = { goPlus: 60, name: "Kimi K3", }, + "kimi-k3.1-pro": { + go: 15, + goPlus: 60, + name: "Kimi K3.1 Pro", + }, "longcat-2.0": { go: 60, goPlus: 240, diff --git a/test/catalog.test.ts b/test/catalog.test.ts index f55281a6..22a0d4df 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -91,7 +91,7 @@ describe("OpenCode Model Catalog & Enrichment", () => { }); it("ships only active, priced models in the bundled Go shim", () => { - expect(OPENCODE_GO_CATALOG.length).toBe(28); + expect(OPENCODE_GO_CATALOG.length).toBe(29); for (const model of OPENCODE_GO_CATALOG) { expect(model.id.length).toBeGreaterThan(0); expect(model.name.length).toBeGreaterThan(0); From 2ebaffc2746cd27b93c9fa9fdd514af9af9767ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 13:15:28 +0000 Subject: [PATCH 12/17] chore: refresh the generated catalog and limits tables The vendor renamed the Go plane's one Gemini model (dropping its contributor suffix, halving its price and publishing an allowance for it where the limits table had none) and added a free model with no SDK, so `catalog freshness` went red on every branch of this stack. This takes the two generated files exactly as the nightly refresh produced them (#111), with no hand edits, and follows them: - the Go Gemini fixtures in six test files (attachment-services, catalog, go-google, go-google-prepare, models-discovery, responses-routes) now name the renamed id, since each means "the Go Gemini model the catalog serves"; - the usage panel test built its unknown allowance from the table's one unpriced row, which the vendor has now priced, so it builds the `Number.NaN` allowance the generator emits itself; - the Go shim count in catalog.test.ts (29 -> 30); - redirect-planes.md: Go 29 -> 30 served, the no-SDK Go cell 18 -> 19, and "0 of 29" -> "0 of 30" in the prose; - both READMEs: the renamed Gemini id, and the new model in the Go completions list, since it declares no SDK. The listed half of "30 served / 34 listed" is models.dev's count, inferred from one rename (no change) and one addition (+1), because models.dev cannot be reached from here. The dated S0 measurement row in improvement-plan.md keeps the id it was measured with. It no-ops once main carries #111. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- docs/redirect-planes.md | 6 +++--- src/catalog-data.ts | 24 +++++++++++++++++++----- src/go-limits-data.ts | 8 ++++---- test/attachment-services.test.ts | 10 +++------- test/catalog.test.ts | 12 +++--------- test/go-google-prepare.test.ts | 2 +- test/go-google.test.ts | 2 +- test/models-discovery.test.ts | 4 +--- test/responses-routes.test.ts | 6 +++--- test/usage-panel.test.tsx | 11 ++++++----- 12 files changed, 48 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index d011a981..9b73ac52 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,9 @@ DSH speaks three of the four families itself. The fourth — Google Generative A ### 2. OpenCode Go (`provider: opencode-go`) — subscription quota -- **Google Generative AI** (`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`): `gemini-4-argon-contributor`, served by the internal `opencode-go-google` adapter. Add the model under your existing `opencode-go` provider; the bridge inherits that provider's API key reference (default `OPENCODE_GO_API_KEY`). It supports image attachments and signed tool-call replay. Only configured models are mounted; access still depends on your Go plan. This endpoint is also used with `endpoint: legacy`, because Go has no legacy Google endpoint. +- **Google Generative AI** (`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`): `gemini-4-argon`, served by the internal `opencode-go-google` adapter. Add the model under your existing `opencode-go` provider; the bridge inherits that provider's API key reference (default `OPENCODE_GO_API_KEY`). It supports image attachments and signed tool-call replay. Only configured models are mounted; access still depends on your Go plan. This endpoint is also used with `endpoint: legacy`, because Go has no legacy Google endpoint. -- **OpenAI Chat Completions** (`https://opencode.ai/zen/go/v1/chat/completions`): `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` +- **OpenAI Chat Completions** (`https://opencode.ai/zen/go/v1/chat/completions`): `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `iceman-contributor-free`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` - **OpenAI Responses** (`https://opencode.ai/zen/go/v1/responses`): `gpt-5.6-luna`, `gpt-6-luna`, `grok-4.5`, `grok-4.6`, `grok-4.7`, `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor` - **Anthropic Messages** (`https://opencode.ai/zen/go/v1/messages`): `claude-haiku-5-5`, `minimax-m2.7`, `minimax-m3`, `qwen3.7-plus`, `qwen3.8-flash`, `qwen3.8-max` - Monitored by the live 3-window quota meter (5-hour rolling, weekly, monthly). The full set ships in the bundled catalog — see [Authoritative Model Catalogs](#7-authoritative-model-catalogs-dual-local-shims--real-time-swr-updates). diff --git a/README.zh-CN.md b/README.zh-CN.md index d10d494a..fcddca72 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,9 +144,9 @@ DSH 能说其中四种,插件就把这四种都路由掉。**Google Generative ### 2. OpenCode Go (`provider: opencode-go`) — 订阅额度 -- **Google Generative AI**(`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`):`gemini-4-argon-contributor`,由内部适配器 `opencode-go-google` 服务。在现有 `opencode-go` 提供商下配置该模型即可,适配器继承该提供商的 API Key 引用(默认 `OPENCODE_GO_API_KEY`),支持图片附件和带签名的工具调用回放。只挂载已配置的模型,可用范围仍由 Go 套餐决定。Go 没有旧版 Google 接口,因此 `endpoint: legacy` 也使用此地址。 +- **Google Generative AI**(`https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent`):`gemini-4-argon`,由内部适配器 `opencode-go-google` 服务。在现有 `opencode-go` 提供商下配置该模型即可,适配器继承该提供商的 API Key 引用(默认 `OPENCODE_GO_API_KEY`),支持图片附件和带签名的工具调用回放。只挂载已配置的模型,可用范围仍由 Go 套餐决定。Go 没有旧版 Google 接口,因此 `endpoint: legacy` 也使用此地址。 -- **OpenAI Chat Completions**(`https://opencode.ai/zen/go/v1/chat/completions`):`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` +- **OpenAI Chat Completions**(`https://opencode.ai/zen/go/v1/chat/completions`):`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`, `deepseek-v4.1-flash`, `glm-5.2`, `glm-5.3`, `glm-5.3-flash`, `hy3`, `hy4-preview`, `iceman-contributor-free`, `kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`, `kimi-k3.1-pro`, `longcat-2.0`, `mimo-v2.5`, `mimo-v2.5-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `qwen3.6-plus`, `qwen3.7-max`, `space-bunny`, `space-bunny-free`, `step-5-preview-free` - **OpenAI Responses**(`https://opencode.ai/zen/go/v1/responses`):`gpt-5.6-luna`, `gpt-6-luna`, `grok-4.5`, `grok-4.6`, `grok-4.7`, `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor` - **Anthropic Messages**(`https://opencode.ai/zen/go/v1/messages`):`claude-haiku-5-5`, `minimax-m2.7`, `minimax-m3`, `qwen3.7-plus`, `qwen3.8-flash`, `qwen3.8-max` - 由实时三窗口额度计量监控(5 小时滚动、每周、每月)。完整集合随内置目录发布——参见[权威模型目录](#7-权威模型目录双本地预置--实时-swr-更新)。 diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 9bb3cc14..b210b546 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -11,7 +11,7 @@ A **plane** is one gateway base URL with its own catalog and its own credential. | Plane | Route id | Base URL | Credential | Models | | :-- | :-- | :-- | :-- | --: | | Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | `OPENCODE_API_KEY` | 82 served / 127 listed | -| Go (subscription) | `opencode-go` | the `endpoint` family's | `OPENCODE_GO_API_KEY` | 29 served / 33 listed | +| Go (subscription) | `opencode-go` | the `endpoint` family's | `OPENCODE_GO_API_KEY` | 30 served / 34 listed | **There is no longer one base URL per plane to quote.** The gateway answers on two endpoint families and the `endpoint` setting picks one: `auto` follows the catalog, which today names `…/inference/openai/v1` and `…/inference/go/openai/v1`, and `legacy` pins the older `…/zen/v1` and `…/zen/go/v1`. Both are still served and they are entitled separately, which is why the choice is the user's rather than a migration. The dated measurement below was taken on the legacy family. @@ -25,13 +25,13 @@ A **shape** is one wire protocol, named by the SDK a model declares in the catal | SDK in the catalog | Protocol | Zen | Go | | :------------------ | :---------------------- | --: | ----: | -| _(none)_ | `openai-completions` | 25 | 18 | +| _(none)_ | `openai-completions` | 25 | 19 | | `@ai-sdk/openai` | `openai-responses` | 31 | **5** | | `@ai-sdk/anthropic` | `anthropic-messages` | 17 | **5** | | `@ai-sdk/mistral` | `mistral-conversations` | 1 | — | | `@ai-sdk/google` | `google-generative-ai` | 8 | **1** | -**Absence is the signal, and it is a real one.** Both planes set a provider-level `npm` of `@ai-sdk/openai-compatible` in `models.dev`, and **no model declares it explicitly**: 0 of 82 on Zen, 0 of 29 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 82 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. diff --git a/src/catalog-data.ts b/src/catalog-data.ts index a140dfb2..30904b62 100644 --- a/src/catalog-data.ts +++ b/src/catalog-data.ts @@ -182,14 +182,14 @@ export const OPENCODE_GO_CATALOG: readonly CatalogModelSpec[] = [ { context_window: 1_048_576, cost: { - cache_read: 0.2, - input: 4, - output: 20, + cache_read: 0.1, + input: 2, + output: 10, }, - id: "gemini-4-argon-contributor", + id: "gemini-4-argon", input_modalities: ["text", "image"], max_output_tokens: 65_536, - name: "Gemini 4 Argon Contributor", + name: "Gemini 4 Argon", provider_npm: "@ai-sdk/google", reasoning: true, reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }], @@ -323,6 +323,20 @@ export const OPENCODE_GO_CATALOG: readonly CatalogModelSpec[] = [ reasoning: true, reasoning_options: [{ type: "effort", values: ["none", "high"] }], }, + { + context_window: 262_144, + cost: { + cache_read: 0, + input: 0, + output: 0, + }, + id: "iceman-contributor-free", + input_modalities: ["text"], + is_free: true, + max_output_tokens: 131_072, + name: "Iceman Contributor Free", + reasoning: true, + }, { context_window: 262_144, cost: { diff --git a/src/go-limits-data.ts b/src/go-limits-data.ts index d71e0343..0e6fc62f 100644 --- a/src/go-limits-data.ts +++ b/src/go-limits-data.ts @@ -27,10 +27,10 @@ export const GO_MODEL_LIMITS: Readonly> = { goPlus: 160, name: "DeepSeek V4.1 Flash (Off-Peak)", }, - "gemini-4-argon-contributor": { - go: Number.NaN, - goPlus: Number.NaN, - name: "Gemini 4 Argon Contributor", + "gemini-4-argon": { + go: 30, + goPlus: 120, + name: "Gemini 4 Argon (50% off)", }, "glm-5.2": { go: 60, diff --git a/test/attachment-services.test.ts b/test/attachment-services.test.ts index f1647c72..fa8ecf05 100644 --- a/test/attachment-services.test.ts +++ b/test/attachment-services.test.ts @@ -58,9 +58,7 @@ it("releases optional service scopes when adapter registration fails", () => { host: { PiAiAdapter }, plane: "opencode-go", apiKeyRef: "UNUSED_KEY", - specs: OPENCODE_GO_CATALOG.filter( - (spec) => spec.id === "gemini-4-argon-contributor" - ), + specs: OPENCODE_GO_CATALOG.filter((spec) => spec.id === "gemini-4-argon"), llm: { registerAdapter: () => { throw new Error("registration failed"); @@ -145,9 +143,7 @@ it("streams a durable image through the actual Go Gemini adapter", async () => { host: { PiAiAdapter }, plane: "opencode-go", apiKeyRef: "BRIDGE_IMAGE_KEY", - specs: OPENCODE_GO_CATALOG.filter( - (spec) => spec.id === "gemini-4-argon-contributor" - ), + specs: OPENCODE_GO_CATALOG.filter((spec) => spec.id === "gemini-4-argon"), llm: { registerAdapter: (_routes, value) => { adapter = value as typeof adapter; @@ -165,7 +161,7 @@ it("streams a durable image through the actual Go Gemini adapter", async () => { try { for await (const _chunk of adapter!.stream({ provider: "opencode-go-google", - model: "gemini-4-argon-contributor", + model: "gemini-4-argon", messages: [ { role: "user", diff --git a/test/catalog.test.ts b/test/catalog.test.ts index 22a0d4df..4224f9ea 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -91,7 +91,7 @@ describe("OpenCode Model Catalog & Enrichment", () => { }); it("ships only active, priced models in the bundled Go shim", () => { - expect(OPENCODE_GO_CATALOG.length).toBe(29); + expect(OPENCODE_GO_CATALOG.length).toBe(30); for (const model of OPENCODE_GO_CATALOG) { expect(model.id.length).toBeGreaterThan(0); expect(model.name.length).toBeGreaterThan(0); @@ -371,9 +371,7 @@ describe("OpenCode Model Catalog & Enrichment", () => { data: Array<{ id: string }>; }; // The GO catalog's own rows are in it… - expect(goJson.data.some((m) => m.id === "gemini-4-argon-contributor")).toBe( - true - ); + expect(goJson.data.some((m) => m.id === "gemini-4-argon")).toBe(true); expect(goJson.data.some((m) => m.id === "muse-spark-1.3-contributor")).toBe( true ); @@ -1167,11 +1165,7 @@ describe("routing · the plane half of the decision", () => { it("leaves a model with no protocol alone", () => { expect( - internalRouteFor( - "opencode-go", - "gemini-4-argon-contributor", - "@ai-sdk/google" - ) + internalRouteFor("opencode-go", "gemini-4-argon", "@ai-sdk/google") ).toBe("opencode-go-google"); expect(internalRouteFor("opencode-go", "grok-4.7")).toBeUndefined(); }); diff --git a/test/go-google-prepare.test.ts b/test/go-google-prepare.test.ts index ff9fd504..10389a22 100644 --- a/test/go-google-prepare.test.ts +++ b/test/go-google-prepare.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveConfig } from "../src/config.ts"; import { decoratePlaneAdapters } from "../src/models-discovery.ts"; -const model = "gemini-4-argon-contributor"; +const model = "gemini-4-argon"; const route = "opencode-go-google"; describe("Go Google preparation", () => { diff --git a/test/go-google.test.ts b/test/go-google.test.ts index edbb292a..4a69e91c 100644 --- a/test/go-google.test.ts +++ b/test/go-google.test.ts @@ -48,7 +48,7 @@ const createCredentialsSeam = () => { }; }; -const model = "gemini-4-argon-contributor"; +const model = "gemini-4-argon"; const specs = OPENCODE_GO_CATALOG.filter((spec) => spec.id === model); describe("Go Gemini native bridge", () => { diff --git a/test/models-discovery.test.ts b/test/models-discovery.test.ts index 2fa6706c..dcada068 100644 --- a/test/models-discovery.test.ts +++ b/test/models-discovery.test.ts @@ -575,9 +575,7 @@ describe("models-discovery: I1's second clause, for a model no catalog describes it("offers Go Gemini through its native bridge", () => { // The Go catalog names the Google protocol, served by the Go bridge. - expect(isServableModel("gemini-4-argon-contributor", "opencode-go")).toBe( - true - ); + expect(isServableModel("gemini-4-argon", "opencode-go")).toBe(true); }); it("offers Zen's free tier models normally", () => { diff --git a/test/responses-routes.test.ts b/test/responses-routes.test.ts index cc0f79c7..adc1366c 100644 --- a/test/responses-routes.test.ts +++ b/test/responses-routes.test.ts @@ -112,9 +112,9 @@ describe("responses-routes: the SDK mapping", () => { expect(internalRouteFor("opencode", "gemini-3-pro", GOOGLE_SDK)).toBe( GOOGLE_INTERNAL_ROUTE ); - expect( - internalRouteFor("opencode-go", "gemini-4-argon-contributor", GOOGLE_SDK) - ).toBe("opencode-go-google"); + expect(internalRouteFor("opencode-go", "gemini-4-argon", GOOGLE_SDK)).toBe( + "opencode-go-google" + ); }); it("leaves an SDK with no protocol anywhere on its own route", () => { diff --git a/test/usage-panel.test.tsx b/test/usage-panel.test.tsx index dc2ec501..602b2a72 100644 --- a/test/usage-panel.test.tsx +++ b/test/usage-panel.test.tsx @@ -12,7 +12,6 @@ import assert from "node:assert/strict"; import { MenuSurface } from "@deepseek-ai/dsh-client-ui-primitives"; import { describe, expect, it, vi } from "vitest"; -import { GO_MODEL_LIMITS } from "../src/go-limits-data.ts"; import type { SessionUsageSnapshot } from "../src/session-cost.ts"; import { en as copyEn, zh as copyZh } from "../src/settings-copy.ts"; import type { GoUsage } from "../src/usage-contract.ts"; @@ -52,11 +51,13 @@ const usage = (overrides: Partial = {}): GoUsage => ({ ...overrides, }); -it("renders unknown Gemini allowances before and after the JSON transport", () => { - const model = "gemini-4-argon-contributor"; - const limits = GO_MODEL_LIMITS[model]!; +it("renders unknown allowances before and after the JSON transport", () => { + // The generator writes `Number.NaN` for a model the vendor's page does not + // price. Whether today's table holds such a row is the vendor's choice, so + // the allowance is built here in the shape the generator emits. + const model = "gemini-4-argon"; const raw = usage({ - allowance: { model, go: limits.go, goPlus: limits.goPlus }, + allowance: { model, go: Number.NaN, goPlus: Number.NaN }, }); // JSON turns the catalog's NaN into null across the host/client boundary. // oxlint-disable-next-line unicorn/prefer-structured-clone -- exercise the host's JSON transport From d51a3e6d77b7fdd764f75c36840578fb7e54e82a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 13:19:58 +0000 Subject: [PATCH 13/17] test: skip free models the gateway has withdrawn in the live suite The live routing suite picks its free-tier subjects from the catalog by asking the gateway, but it counted "400 Upstream request failed: Model is unavailable" as an observable answer. The upstream withdrew nemotron-3-ultra-free while the catalog still lists it, so the picker chose it and two cases asserted on a model the gateway no longer serves; the reachability case also failed on a 429 throttle. A withdrawn model (that 400, or a 410) is now skipped like a throttled one. The free-tier routing case only accepts a candidate whose answer is the free-tier gate it asserts, so an unmetered model cannot become its subject, and the reachability walk moves past gated, withdrawn and throttled candidates before it reports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- test/e2e/protocol-routing.e2e.ts | 54 +++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/test/e2e/protocol-routing.e2e.ts b/test/e2e/protocol-routing.e2e.ts index df1e68fb..8b04a079 100644 --- a/test/e2e/protocol-routing.e2e.ts +++ b/test/e2e/protocol-routing.e2e.ts @@ -265,6 +265,19 @@ const otherProtocols = (expected: string) => const freeTierClosed = (result: Probe): boolean => result.summary.includes("FreeTierError"); +/** + * Whether the gateway answered that it no longer serves the MODEL at all. + * + * `400 Upstream request failed: Model is unavailable` and `410` are the gateway + * speaking about the model rather than the route: the catalog can still list an + * id the upstream has withdrawn (measured 2026-10-26 on `nemotron-3-ultra-free`, + * answered identically on two runs a minute apart). Such a candidate is skipped + * like any other the gateway left nothing to assert about, never asserted on. + */ +const modelGone = (result: Probe): boolean => + result.status === 410 || + (result.status === 400 && result.summary.includes("Model is unavailable")); + const accessDisabled = (result: Probe): boolean => result.status === 402 || result.summary.includes("Insufficient account funds") || @@ -342,11 +355,21 @@ describe.skipIf(!LIVE)("live protocol routing", () => { candidates: readonly string[], protocol: string, key?: string, - base: string = ZEN_BASE + base: string = ZEN_BASE, + // A case that asserts one particular answer narrows the pick to candidates + // that give it, so an otherwise observable answer of another kind (an + // unmetered 200 where the case asserts the free-tier gate) moves the walk on + // instead of being asserted on. + accept: (answer: Probe) => boolean = () => true ): Promise => { for (const model of candidates) { const answer = await probe(protocol, model, key, base); - if (!accessDisabled(answer) && answer.status !== 0) { + if ( + !accessDisabled(answer) && + !modelGone(answer) && + answer.status !== 0 && + accept(answer) + ) { return model; } } @@ -359,11 +382,19 @@ describe.skipIf(!LIVE)("live protocol routing", () => { // The vendor catalog no longer lists a free model on /responses, so the // free-tier routing case now asserts the COMPLETIONS side of the split. // The subject is picked live from the free slice, in catalog order, so - // whichever free model is alive today is the one tested. + // whichever GATED free model is alive today is the one tested: the case + // asserts the free-tier gate, so an unmetered model (which answers 200 + // everywhere) cannot be its subject. const candidates = OPENCODE_ZEN_CATALOG.filter((m) => m.is_free).map( (m) => m.id ); - const model = await pickModel(candidates, "openai-completions"); + const model = await pickModel( + candidates, + "openai-completions", + undefined, + ZEN_BASE, + freeTierClosed + ); if (model === undefined) { context.skip(); return; @@ -838,17 +869,24 @@ describe.skipIf(!LIVE)("live protocol routing", () => { servedProtocolFor(model) ?? "openai-completions", model ); - if (freeTierClosed(response)) { + // A gated, withdrawn or throttled candidate says nothing about whether + // the free tier is reachable, so the walk moves on to the next one. + if ( + freeTierClosed(response) || + modelGone(response) || + accessDisabled(response) + ) { continue; } answered = { model, status: response.status }; break; } if (answered === undefined) { - // Reported rather than skipped: the account (or the vendor) closed the - // whole free tier, and that is worth a line in the run's output. + // Reported rather than skipped: the account (or the vendor) closed, + // withdrew or throttled the whole free tier, and that is worth a line + // in the run's output. console.log( - `[e2e] every free candidate answered FreeTierError — the free tier needs a session token this plugin does not hold; see docs/opencode-1.19-api.md. The header-restoration case did not run.` + `[e2e] no free candidate answered with a completion — each was gated (FreeTierError: the free tier needs a session token this plugin does not hold; see docs/opencode-1.19-api.md), withdrawn upstream, or throttled. The header-restoration case did not run.` ); return; } From 059236a6a09c796dbb35a650b2f85e65c061f358 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:27:01 +0000 Subject: [PATCH 14/17] ci: gate freshness only on a pull request's own generator changes The vendor moves its model catalog, its Go limits page and its CLI version almost daily. Each move turned every open pull request red, although none of them caused it: vendor drift is not a regression of the change under test. A pull request now fails a freshness check only when its diff changes that check's generator code: the script, every local module it loads, transitively, and the pinned version file for the CLI check. scripts/freshness-scope.ts derives those inputs from the sources and makes the call, so a new import moves them without a list to edit. Any other pull request reports drift as a warning, as a push to main already did. The offline check that every copy of the CLI version agrees with the pin still gates every pull request, since only the repository can break it. The gates fail closed: a lost scope answer gates rather than skips. The nightly run still regenerates the two tables and proposes the refresh as its own pull request, and warns on the CLI version. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- .github/workflows/ci.yml | 153 +++-- AGENTS.md | 1 + CONTRIBUTING.md | 22 +- README.md | 2 +- README.zh-CN.md | 2 +- docs/deep-dive.md | 2 +- docs/engineering-notes.md | 2 +- docs/protocol-routing-and-merge.md | 2 +- package.json | 4 +- pnpm-lock.yaml | 6 + scripts/check-vendor-version.ts | 40 +- scripts/freshness-scope.ts | 316 ++++++++++ scripts/generate-go-limits.ts | 5 +- src/go-limits.ts | 8 +- test/freshness-scope.test.ts | 928 +++++++++++++++++++++++++++++ 15 files changed, 1444 insertions(+), 49 deletions(-) create mode 100644 scripts/freshness-scope.ts create mode 100644 test/freshness-scope.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6db0aa08..cc3ed67a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,21 +90,51 @@ jobs: # diff to review instead of a bug to diagnose. # # WHAT THIS GATES, and what it deliberately does not. The failure it exists - # for is an OMISSION: someone changes the catalog and forgets to regenerate, - # so the wrong `provider_npm` ships. That is fixable before merging, so a - # PULL REQUEST fails on a stale shim. + # for is an OMISSION: someone changes a generator - or the parser it calls - + # and forgets to regenerate, so the wrong `provider_npm` ships. That is a fact + # about a pull request's OWN diff, so the diff decides. On a pull request each + # of the three checks (`catalog:shim`, `limits:shim`, `cli:version`) GATES + # only when the diff touches one of that check's INPUTS: its script and every + # local module the script loads, transitively (a type-only import loads + # nothing), plus `src/identity.ts`, which `cli:version` reads as data. + # Otherwise it REPORTS - a warning, exit 0. One half of `cli:version` gates + # every pull request regardless: the offline scan that every copy of the CLI + # version in the tree agrees with the pin, which reads only the files the + # repository maintains (never the generated tables, which hold vendor data), + # so no vendor move can break it. + # `scripts/freshness-scope.ts` derives the inputs from the sources themselves + # and makes the call, so adding an import to a generator moves its inputs + # without anyone editing a list. The generated files are never inputs: a pull + # request that only carries a refresh reports drift rather than failing on it, + # and one that edits a generated file without touching its generator gets a + # warning naming the file, since a hand-edit is forbidden. # - # 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. + # 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. # - # Fork PRs are skipped: the job reaches the public internet, and a fork's - # copy would answer "stale" for everyone. + # 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. + # + # 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 @@ -116,62 +146,113 @@ jobs: pull-requests: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Two commits: on a pull request HEAD is the merge ref, whose first + # parent is the base, so `HEAD^1..HEAD` is exactly the PR's own diff. + # The default depth of one would leave `HEAD^1` missing. + fetch-depth: 2 - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 26 cache: pnpm - run: pnpm install --frozen-lockfile - - name: is src/catalog-data.ts current? (pull requests) + - name: which checks does this pull request gate? (pull requests) + id: scope if: github.event_name == 'pull_request' run: | - # Exits 1 on a stale shim and prints what would change. A PR is where - # a forgotten regeneration is still fixable, so this one is a gate. + changed="$RUNNER_TEMP/changed-files" + git diff --name-only HEAD^1 HEAD > "$changed" + # Printed, so the decision below can be checked against what it read. + echo "this pull request changes $(wc -l < "$changed") file(s):" + cat "$changed" + # decide : "gate" when the diff touches one of + # the check's inputs, "report" otherwise. Any warning the script + # raises (a generated file changed without its generator) goes to + # stderr, kept apart from the answer and printed after it. + decide() { + notes="$RUNNER_TEMP/scope-notes" + scope=$(node --experimental-strip-types --disable-warning=ExperimentalWarning \ + scripts/freshness-scope.ts "$2" < "$changed" 2> "$notes") \ + || { cat "$notes"; exit 1; } + cat "$notes" + case "$scope" in + gate | report) ;; + *) echo "::error::freshness-scope answered '$scope' for $2"; exit 1 ;; + esac + echo "$2: $scope" + echo "$1=$scope" >> "$GITHUB_OUTPUT" + } + decide catalog catalog:shim + decide limits limits:shim + decide cli cli:version + - name: is src/catalog-data.ts current? (pull requests touching its generator) + if: github.event_name == 'pull_request' && steps.scope.outputs.catalog != 'report' + run: | + # Exits 1 on a stale shim and prints what would change. This pull + # request changed what generates the shim, so a forgotten + # regeneration is its own omission, and still fixable: a gate. pnpm run catalog:shim || { echo "::error::src/catalog-data.ts is out of date — run 'pnpm run catalog:shim -- --write' and commit the result." exit 1 } - - name: report a stale catalog (pushes to main) - if: github.event_name == 'push' + - name: report a stale catalog (other pull requests, and pushes to main) + if: github.event_name == 'push' || (github.event_name == 'pull_request' && steps.scope.outputs.catalog == 'report') run: | - # A warning, not a failure: main was reviewed with a current shim, and - # the vendor moving since then is not a regression this push caused. - # The nightly run proposes the refresh. + # A warning, not a failure. A pull request that touched no input of + # the generator, or a push to main: vendor drift is not a regression + # of the change under test. The nightly run proposes the refresh. pnpm run catalog:shim \ - || echo "::warning::src/catalog-data.ts is stale - the registry moved since this commit; the nightly refresh will propose it." + || echo "::warning::src/catalog-data.ts is stale - the registry moved; this run does not gate on vendor drift, and the nightly refresh will propose it." # The Go plan's per-model dollar allowance, from OpenCode's own docs. It # moves when THEY move — the page says limits "may change as we learn from # early usage and feedback" — and a stale table shows a wrong dollar figure # that looks authoritative, which is worse than showing none. - - name: is src/go-limits-data.ts current? (pull requests) - if: github.event_name == 'pull_request' + - name: is src/go-limits-data.ts current? (pull requests touching its generator) + if: github.event_name == 'pull_request' && steps.scope.outputs.limits != 'report' run: | pnpm run limits:shim || { echo "::error::src/go-limits-data.ts is out of date — run 'pnpm run limits:shim -- --write' and commit the result." exit 1 } - - name: report a stale limits table (pushes to main) - if: github.event_name == 'push' + - name: report a stale limits table (other pull requests, and pushes to main) + if: github.event_name == 'push' || (github.event_name == 'pull_request' && steps.scope.outputs.limits == 'report') run: | pnpm run limits:shim \ - || echo "::warning::src/go-limits-data.ts is stale - the vendor's docs page moved since this commit." + || echo "::warning::src/go-limits-data.ts is stale - the vendor's docs page moved; this run does not gate on vendor drift, and the nightly refresh will propose it." # The nightly run exists to NOTICE vendor drift, and drift is the normal # case - the sources move most days. Failing here meant a red run every # night, which trains everyone to ignore the signal. So the nightly path - # regenerates and proposes a pull request, and the refresh gates above - # run on pull requests (where a forgotten regeneration is still fixable) - # while a push to main only reports. + # regenerates and proposes a pull request; the refresh gates above run on + # a pull request that touches a generator (where a forgotten regeneration + # is still fixable); any other pull request, and a push to main, only + # reports. A manual dispatch runs neither shim check - only the CLI + # version's report below. # # The User-Agent names the CLI version, and the vendor moves it every few - # days - which is the same shape again: a vendor release is not a reason - # to go red, so this one warns everywhere except a pull request. - - name: the claimed CLI version is the published one (pull requests) + # days - which is the same shape again, with one difference: the script + # checks two things. Every copy of the version in the tree agreeing with + # `src/identity.ts` is offline, and only the repository can break it, so + # it gates every pull request. The pin being the published version follows + # the vendor, so it gates only a pull request that touches the pin or the + # script, and warns everywhere else - quoting the script's own `error:` + # line, so the warning names the cause that failed. + - name: every copy of the CLI version agrees with the pin (pull requests) if: github.event_name == 'pull_request' + run: pnpm run cli:version -- --copies + - name: the claimed CLI version is the published one (pull requests touching it) + if: github.event_name == 'pull_request' && steps.scope.outputs.cli != 'report' run: pnpm run cli:version - - name: report a stale CLI version (push and nightly) - if: github.event_name != 'pull_request' - run: pnpm run cli:version || echo "::warning::the pinned opencode CLI version is not the published one" + - name: report a stale CLI version (other pull requests, push, nightly and dispatch) + if: github.event_name != 'pull_request' || steps.scope.outputs.cli == 'report' + run: | + # The default shell has no pipefail, without which `tee` would answer + # for the check and the warning below could never fire. + set -o pipefail + log="$RUNNER_TEMP/cli-version.log" + pnpm run cli:version 2>&1 | tee "$log" \ + || echo "::warning::$(grep -m 1 '^error: ' "$log" || echo 'cli:version failed - see the log above')" - name: propose the refresh (nightly only) if: github.event_name == 'schedule' diff --git a/AGENTS.md b/AGENTS.md index 1022cade..d1a145a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,7 @@ 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. `allowanceFor` is the one lookup: `attachAllowance` in `usage.ts` calls it rather than inlining a second copy, which it used to. - `src/go-limits-data.ts` — the Go plan allowances, per model and per tier, **generated** from the vendor's own page by `scripts/generate-go-limits.ts`; never hand-edit it, and regenerate with `pnpm run limits:shim -- --write`. The scheduled freshness job is what notices when the page moves, which it did: the docs went under `/v2/docs/console/` and serve HTML now, so the generator reads a table off that page rather than the markdown it used to. +- `scripts/freshness-scope.ts` — which of CI's three freshness checks (`catalog:shim`, `limits:shim`, `cli:version`) a pull request is GATED on: the ones whose inputs (the check's script and the local modules it loads, walked from the source as Node loads it — types erased by Node's own stripper, specifiers read by `es-module-lexer` — plus `src/identity.ts` for the CLI version; never the generated files) its own diff touches. The rest only warn, because vendor drift is not the pull request's regression: for the two generated tables the nightly run regenerates them and proposes the refresh, and for the CLI version it warns and the bump is a human pull request, which then gates. The gates fail closed: each runs unless the scope step answered `report`. The CLI version's offline half — every copy in the tree agrees with the pin, `cli:version -- --copies` — gates every pull request, since it skips the generated tables and only the repository can break it. `test/freshness-scope.test.ts` walks the real generators, runs the scope step's own shell and the CLI it calls, and evaluates the workflow's step conditions. - `src/responses-routes.ts` · `src/responses-provider.ts` — the protocol table and the in-process mount of the host's `llm-pi-ai`. - `src/adapter-replacement.ts` — taking a provider route over from the adapter that already serves it: `llm.registerAdapter` refuses a route the host owns (`DUPLICATE_ADAPTER`), so the current entry is released first, our adapter is registered through the ordinary API, and the original is put back on disposal. The step towards serving a mixed-protocol route as the same provider the user configured, instead of mounting hidden ones. **Not wired yet**: nothing in `src/` imports it, only its own test, so read it as the next step rather than as live behaviour. - `src/purge-service.ts` · `src/purge-contract.ts` — the card's "purge deprecated models" action, and the shape it answers with. It **rewrites the profile**, deleting the declared model entries the live catalog has retired, through `configEditor` (the same service the settings UI writes with). That is deliberate: a read-time filter would leave the declaration in place, and then the only way to explain a missing row is to find the filter. One source of truth, and it is the configuration. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a54c144..0f5331c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,9 @@ pnpm run test:shuffle # the same run in a random order; reproduce a failure w 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 `. @@ -77,6 +79,24 @@ The web profile wires this checkout with `link:`, so builds are picked up like t `src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. `responses-provider.ts` and `responses-routes.ts` carry their own per-file floors: the mount has four host contracts to survive, and a well-covered average must not be able to hide it. +## The freshness checks in CI + +The `catalog freshness` job runs three checks against what the vendor publishes: `catalog:shim` (the bundled catalog, `src/catalog-data.ts`), `limits:shim` (the Go allowances, `src/go-limits-data.ts`) and `cli:version` (the pinned CLI version in `src/identity.ts`). The vendor moves all three most days, so what a run does with a mismatch depends on whether the change under test could have caused it. + +- **A pull request is gated on a check only when its own diff touches that check's inputs.** The inputs are the check's script and every local module it loads, transitively, plus `src/identity.ts` for `cli:version`, which the script reads as data. The edges are the ones Node loads: the source's types are erased by Node's own stripper and the specifiers read by a real lexer, so a whole-statement `import type` loads nothing and is no edge, and an import written in a string or template is not one either; anything else is, so the set reaches further than the generator's own folder: the catalog parser loads `src/config.ts`, so a settings change gates `catalog:shim`. `scripts/freshness-scope.ts` derives the inputs from the sources and answers `gate` or `report` for the diff (`git diff --name-only HEAD^1 HEAD` on the merge ref). Change `src/models-catalog.ts` and forget to regenerate, and the pull request fails; that omission is what the gate exists to catch. +- **One half of `cli:version` gates every pull request.** The script checks two things: that every copy of the CLI version in `src/`, `test/`, `docs/`, `scripts/` and `README.md` agrees with `src/identity.ts`, and that the pin is the published version. The first is offline and skips the two generated tables (they hold the vendor's data, not copies the repository maintains), so only the repository can break it, and `pnpm run cli:version -- --copies` runs on every pull request as a gate. Only the registry comparison is scoped as above, and when it only warns, the warning quotes the script's own `error:` line. +- **Otherwise the check reports.** A stale file is a `::warning::`, the job stays green, and the nightly run regenerates both files and opens the refresh as a pull request of its own. A stale CLI pin is only warned about, by every run including the nightly; bumping it is a human pull request to `src/identity.ts` and its copies, which is then gated, because the pin is an input. Vendor drift is not a regression your pull request introduced, so it is not a reason to fail it. +- **The generated files are never inputs.** A pull request that carries only a refresh reports. One that changes `src/catalog-data.ts` or `src/go-limits-data.ts` without touching what generates it gets a warning naming the file, because those files are never edited by hand. +- **A push to `main` reports.** The nightly run regenerates the two generated files and opens the refresh as a pull request; for the CLI version it warns. A manual dispatch only reports the CLI version. None of these paths gates, for the same reason. +- **The gates fail closed.** Each runs unless the scope step answered `report`, so a lost answer gates every pull request rather than skipping the check. + +To see the answer for your branch before CI does: + +```sh +git diff --name-only main... \ + | node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/freshness-scope.ts catalog:shim +``` + ## Release process (maintainers) 1. Use conventional commits (`feat:`, `fix:`) — release-please opens the version-bump + changelog PR automatically; merging it tags and creates the GitHub Release, which fires OIDC publishing to npm and GitHub Packages. diff --git a/README.md b/README.md index 9b73ac52..abea5fd7 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ Merging is **additive**: adapter rows are kept, canonical rows are appended when Two things follow that you will notice in use. **Cold start is never empty** — the bundled shim answers before the first refresh, so the picker has models immediately. And **a model the catalog has no row for still appears**, with `—` where its rate would be: never a guess, and never the previous model's name. -Both generated files are checked in CI (`catalog:shim`, `limits:shim`); they exit non-zero when the vendor's data moved, which is the only way to notice the vendor's registry changing under you. +Both generated files are checked in CI (`catalog:shim`, `limits:shim`); they exit non-zero when the vendor's data moved, which is the only way to notice the vendor's registry changing under you. A pull request fails on that only when it changes the code that generates the file; otherwise CI warns, and a nightly run proposes the refresh as a pull request of its own. → Protocol routing, the merge and the UI in technical detail: [`docs/protocol-routing-and-merge.md`](./docs/protocol-routing-and-merge.md) · [中文](./docs/protocol-routing-and-merge.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index fcddca72..baf73487 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -219,7 +219,7 @@ opencode-responses: 有两点你在使用中会注意到。**冷启动永远不为空**——内置 shim 在第一次刷新前就能回答,选择器立刻有模型可选。而且**目录里没有条目的模型照样出现**,费率位置显示 `—`:既不猜,也不会退回去显示上一个模型的名字。 -两个生成文件都在 CI 里校验(`catalog:shim`、`limits:shim`);厂商数据变动时它们以非零码退出——这是唯一能发现厂商注册表在你背后变了的办法。 +两个生成文件都在 CI 里校验(`catalog:shim`、`limits:shim`);厂商数据变动时它们以非零码退出——这是唯一能发现厂商注册表在你背后变了的办法。只有当拉取请求改动了生成该文件的代码时,CI 才会因此失败;否则只给出警告,由每晚的任务以单独的拉取请求提出刷新。 → 协议路由、合并与界面的技术细节: [`docs/protocol-routing-and-merge.md`](./docs/protocol-routing-and-merge.md) · [English](./docs/protocol-routing-and-merge.md) diff --git a/docs/deep-dive.md b/docs/deep-dive.md index 34c301c9..d6520adb 100644 --- a/docs/deep-dive.md +++ b/docs/deep-dive.md @@ -168,7 +168,7 @@ How does this compare to Duskriver's [`dsh-opencode-go`](https://www.npmjs.com/p OpenCode's gateway `GET …/models` endpoints frequently return a truncated subset: no display names, context windows, max tokens or input modalities. The plugin ships a **stale-while-revalidate** catalog for both planes: -1. Dual bundled shims (zero latency, offline): `OPENCODE_GO_CATALOG` carries every active Go subscription model with per-million-token rates, so session pricing works before the first refresh; `OPENCODE_ZEN_CATALOG` carries the flagships (`claude-sonnet-4-5`, `claude-opus-4-7`, `gpt-5.4`, `gemini-3.8-flash`, `qwen3.8-max`, `kimi-k3`). The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file, and CI fails when the shim is stale. Retired models are excluded so a failed refresh can never resurrect a row the gateway no longer serves: the three Zen-route Muse Spark 1.2 ids are suppressed, while the paid Go 1.2 contributor entry stays (the CLI still lists it). Startup is instant: no cold-start delay, blocking network calls, or airplane-mode failures. +1. Dual bundled shims (zero latency, offline): `OPENCODE_GO_CATALOG` carries every active Go subscription model with per-million-token rates, so session pricing works before the first refresh; `OPENCODE_ZEN_CATALOG` carries the flagships (`claude-sonnet-4-5`, `claude-opus-4-7`, `gpt-5.4`, `gemini-3.8-flash`, `qwen3.8-max`, `kimi-k3`). The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file, and CI checks the shim for drift: a pull request that changes the generator (or what it loads) fails on a stale shim; any other pull request, and a push to main, warns, and the nightly run proposes the refresh. Retired models are excluded so a failed refresh can never resurrect a row the gateway no longer serves: the three Zen-route Muse Spark 1.2 ids are suppressed, while the paid Go 1.2 contributor entry stays (the CLI still lists it). Startup is instant: no cold-start delay, blocking network calls, or airplane-mode failures. 2. Background revalidation: both catalogs revalidate against [`https://models.opencode.ai/api.json`](https://models.opencode.ai/api.json) — the OpenCode catalog, which today serves the byte-identical object as `models.dev/api.json` — every **60 minutes** (the OpenCode CLI's canonical cycle), merging new models, deprecations and updated limits. Errors degrade gracefully and retain the active catalog. 3. Gateway models-endpoint enrichment: `patchFetch` intercepts `GET …/models` on OpenCode routes and merges the live Go or Zen catalog: human-friendly names (`DeepSeek V4.1 Flash`, `Qwen3.8 Flash`, `Grok 4.7`, `MiMo V2.6 Pro`), verified context windows (up to 1,000,000+ tokens) and max output tokens (up to 384,000), correct input modalities (`text`, `image`), with retired Muse Spark 1.2 rows omitted. 4. Settings “Fetch Available Models” decoration: DSH asks the route's own adapter first, and for an installed `opencode` route `llm-pi-ai` answers from its packaged catalog without calling the gateway. The plugin therefore decorates the hosted discovery result: adapter rows and order are preserved, their metadata enriched from the catalog, provider-retired rows removed, and **models whose protocol DSH cannot speak dropped** (offering one could only fail). It **does not append** catalog rows the adapter did not report: an earlier revision did, and that is how a deployment that had declared four models saw eighty-six in Settings. This is candidate metadata for the settings surface; it never rewrites saved route configuration. Widening a deployment's list is a separate, explicit action — the card's **Check catalog** row, which shows what the catalog offers and declares it only when asked. diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index deeb841c..14e9fed4 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -332,7 +332,7 @@ So it is a known gap with a known shape rather than an unknown one: a number in ## The count gap is closed, and nothing else restates one -With the prose totals asserted, every count in this repository is now either parsed by a test or generated by the script that owns it. Swept for the rest: no other document states a catalog number, and `deep-dive.md` avoids the problem the way it should — _"The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file, and CI fails when the shim is stale."_ That is the better pattern, and it is why that file was never wrong about a count while four others were. +With the prose totals asserted, every count in this repository is now either parsed by a test or generated by the script that owns it. Swept for the rest: no other document states a catalog number, and `deep-dive.md` avoids the problem the way it should — _"The counts are deliberately not restated here: they move with the vendor, they are visible in the generated file …"_ That is the better pattern, and it is why that file was never wrong about a count while four others were. So the class has an ending rather than a fifth instance: numbers live where a test reaches them, or they are not written down. diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index 0d7ef7d8..aa840375 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -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/package.json b/package.json index 5438b158..d1ff01cf 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@types/react-dom": "18.3.7", "@types/semver": "^7.8.0", "@vitest/coverage-v8": "5.0.5", + "es-module-lexer": "2.3.2", "jsdom": "^30.1.3", "react": "18.3.1", "react-dom": "18.3.1", @@ -109,7 +110,8 @@ "typescript": "^7.0.2", "ultracite": "^8.0.1", "vite-plus": "^1.3.0", - "vitest": "5.0.5" + "vitest": "5.0.5", + "yaml": "2.9.1" }, "peerDependencies": { "@deepseek-ai/cordis": ">=4.0.5-alpha.2 <4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35a94d0b..cc02dfad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: '@vitest/coverage-v8': specifier: 5.0.5 version: 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5) + es-module-lexer: + specifier: 2.3.2 + version: 2.3.2 jsdom: specifier: ^30.1.3 version: 30.1.3 @@ -236,6 +239,9 @@ importers: vitest: specifier: 5.0.5 version: 5.0.5(@types/node@26.6.5)(@vitest/browser-preview@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.2.0(@types/node@26.6.5)(typescript@7.0.2)(yaml@2.9.1))(jsdom@30.1.3) + yaml: + specifier: 2.9.1 + version: 2.9.1 packages: diff --git a/scripts/check-vendor-version.ts b/scripts/check-vendor-version.ts index 7e30f364..5dffc266 100644 --- a/scripts/check-vendor-version.ts +++ b/scripts/check-vendor-version.ts @@ -23,6 +23,16 @@ * The second version only scanned `opencode/`, which meant a bare copy * was invisible to it: it passed while three e2e comments were two patches * stale. + * + * `--copies` runs the second check alone and stops before the registry. That + * half is offline and reads only files the repository maintains (the generated + * tables, which hold vendor data, are skipped), so only the repository can + * break it, and CI gates every pull request on it. The first half follows the + * vendor, and gates only a pull request that touches the pin or this script + * (`scripts/freshness-scope.ts` decides). + * + * A failure prints one `error: ` line first (the detail after it), so a + * CI step that only warns can quote the cause rather than guess it. */ import { readdir, readFile } from "node:fs/promises"; @@ -31,6 +41,17 @@ import { readdir, readFile } from "node:fs/promises"; const ROOTS = ["src", "test", "docs", "scripts", "README.md"]; const EXTENSIONS = /\.(?:ts|tsx|md|json|ya?ml)$/; +/** + * Not scanned: the generated tables hold the vendor's data, not copies the + * repository maintains, so a version-shaped string arriving in a refresh is + * not a stale copy - and scanning them would let a vendor move fail the gate + * that only the repository is supposed to be able to break. The same list as + * `GENERATED_OUTPUTS` in `scripts/freshness-scope.ts`, kept here rather than + * imported (an import would make that module an input of this check); + * `test/freshness-scope.test.ts` ties the two. + */ +const GENERATED = new Set(["src/catalog-data.ts", "src/go-limits-data.ts"]); + /** The constant every other copy must agree with. */ const CONSTANT = /OPENCODE_CLI_VERSION\s*=\s*"(\d+\.\d+\.\d+)"/; const CONSTANT_FILE = "src/identity.ts"; @@ -58,7 +79,7 @@ const literals = async (pattern: RegExp): Promise> => { } } catch { // Not a directory, so read it as a file when it is one we scan. - if (EXTENSIONS.test(path)) { + if (EXTENSIONS.test(path) && !GENERATED.has(path)) { record(path, await readFile(path, "utf8")); } } @@ -93,6 +114,12 @@ const main = async (): Promise => { `these copies do not say ${pinned}, which is what ${CONSTANT_FILE} declares:\n${detail}` ); } + if (process.argv.includes("--copies")) { + process.stdout.write( + `ok ${[...copies.values()].flat().length} copy/copies and ${[...mentions.values()].flat().length} bare mention/mentions say ${pinned}, as ${CONSTANT_FILE} does (registry not asked)\n` + ); + return; + } const response = await fetch("https://registry.npmjs.org/opencode-ai/latest"); if (!response.ok) { @@ -115,4 +142,13 @@ const main = async (): Promise => { ); }; -await main(); +try { + await main(); +} catch (error) { + // The cause, not a stack: every failure above is a sentence written for the + // reader, and the line a warning step quotes. + process.stderr.write( + `error: ${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; +} diff --git a/scripts/freshness-scope.ts b/scripts/freshness-scope.ts new file mode 100644 index 00000000..9d799662 --- /dev/null +++ b/scripts/freshness-scope.ts @@ -0,0 +1,316 @@ +/** + * Which freshness checks a pull request is GATED on, and which it only REPORTS. + * + * The three freshness checks in CI's `catalog` job (`catalog:shim`, + * `limits:shim`, `cli:version`) compare something checked in against something + * the vendor publishes, and the vendor moves most days. A check that fails + * every open pull request on each move blames the pull request for the + * vendor's change, and a red that is never the reader's fault is one the + * reader learns to skip. + * + * What the gate exists for is an OMISSION: someone changes a generator (or the + * parser it calls) and forgets to regenerate. That is a fact about the pull + * request's own diff, so the diff decides: a check GATES when the diff touches + * one of its INPUTS, and REPORTS (a warning, exit 0) otherwise. + * + * The inputs are code, never output. A check's inputs are its script and every + * local module the script loads, transitively, read from the sources rather + * than listed by hand - so adding an import to a generator moves its inputs + * without anyone remembering to. The edges are the ones Node would load: types + * erased by Node's own stripper, specifiers read by a real lexer, so a + * type-only import is no edge and a string that only looks like an import is + * none either (`importSpecifiers`). The two generated files are never inputs: a + * pull request that only carries a refresh must report drift, not fail on it. + * A pull request that edits a generated file without touching what generates + * it is still named, in a warning (`handEditedOutputs`), because a hand-edit + * is forbidden (AGENTS.md) and should stay visible. + * + * Everything exported is a function of its arguments - the file reader is a + * parameter - so the decision is testable without a filesystem or a diff. The + * CLI at the bottom is the effect half: + * + * ```sh + * git diff --name-only HEAD^1 HEAD \ + * | node --experimental-strip-types --disable-warning=ExperimentalWarning \ + * scripts/freshness-scope.ts catalog:shim + * # prints "gate" or "report" on stdout; any hand-edit warning goes to stderr + * ``` + * + * No network: it reads the changed-file list and the repository's sources. + */ + +import { readFileSync, realpathSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +import { dirname, join, normalize, resolve } from "node:path/posix"; +import { fileURLToPath } from "node:url"; + +import { initSync, parse } from "es-module-lexer"; + +/** The three checks, by the `package.json` script that runs each. */ +export type FreshnessCheck = "catalog:shim" | "cli:version" | "limits:shim"; + +/** What the diff decides for one check. */ +export type FreshnessScope = "gate" | "report"; + +/** One check: the script it runs, inputs it reads as data, and what it generates. */ +export interface FreshnessCheckSpec { + /** The script the check runs; the import walk starts here. */ + readonly entry: string; + /** + * Inputs the script reads as data rather than importing, so the import walk + * cannot see them. `check-vendor-version.ts` reads the pinned version out of + * `src/identity.ts` with a regular expression. + */ + readonly dataInputs: readonly string[]; + /** The generated files the check compares, and its `--write` rewrites. */ + readonly outputs: readonly string[]; +} + +/** + * Every generated file. Never an input, whichever import reaches it. + * `check-vendor-version.ts` keeps its own copy of this list (it skips these in + * its copy scan, since they hold vendor data, not copies the repository + * maintains) rather than importing it, which would make this module one of its + * inputs; `test/freshness-scope.test.ts` ties the two. + */ +export const GENERATED_OUTPUTS: readonly string[] = [ + "src/catalog-data.ts", + "src/go-limits-data.ts", +]; + +export const FRESHNESS_CHECKS: Readonly< + Record +> = { + "catalog:shim": { + dataInputs: [], + entry: "scripts/regenerate-catalog-shim.ts", + outputs: ["src/catalog-data.ts"], + }, + "cli:version": { + dataInputs: ["src/identity.ts"], + entry: "scripts/check-vendor-version.ts", + outputs: [], + }, + "limits:shim": { + dataInputs: [], + entry: "scripts/generate-go-limits.ts", + outputs: ["src/go-limits-data.ts"], + }, +}; + +export const isFreshnessCheck = (value: string): value is FreshnessCheck => + Object.hasOwn(FRESHNESS_CHECKS, value); + +/** + * Every module specifier one source LOADS, as Node would load it: the source's + * types erased by Node's own stripper (`stripTypeScriptTypes`, the same + * erasure `--experimental-strip-types` applies when the generator runs), and + * the specifiers read off the result by a real lexer (`es-module-lexer`), not + * by a pattern. + * + * Both halves matter. The stripper decides what is type-only: a whole-statement + * `import type …` or `export type … from` is erased, so the module is never + * loaded and cannot change what the generator emits (`test/architecture.test.ts` + * skips these edges for the client graph on the same ground); an inline + * `import { type A }` strips to `import {}`, which still loads the module, so + * it IS an edge. The lexer knows strings, templates, regular expressions and + * comments: a `"docs/*"` string or a `/\/*$/u` regex cannot open a fake + * comment that hides the import after it, and an import written inside a + * template is text, not an edge. A hand-written pattern got both wrong, and a + * missed edge is the dangerous direction - it turns a gate into a report. + * + * Throws on a source Node itself could not load in strip mode (an `enum`, a + * `namespace`): every input runs under that flag, so such a file would break + * the generator first, and a throw fails the scope step closed rather than + * reporting a walk that stopped early. A type error in a skipped file is + * `pnpm run check`'s to catch, not freshness's. A dynamic `import()` whose + * argument is not a string literal names no file and is skipped. Only module + * source belongs here: `inputsOf` never hands it a JSON file or other data a + * generator imports, and names the file when this throws. + */ +export const importSpecifiers = (source: string): string[] => { + initSync(); + const [imports] = parse(stripTypeScriptTypes(source)); + return [ + ...new Set( + imports.flatMap((entry) => (entry.n === undefined ? [] : [entry.n])) + ), + ]; +}; + +/** + * The extensions Node parses as JavaScript or TypeScript modules. Any other + * local file a generator imports (`../package.json` with a JSON import + * attribute) is data: an input, but a leaf, never lexed. + */ +const MODULE_EXTENSION = /\.(?:[cm]?[jt]s|tsx)$/u; + +/** Reads one repository-relative path, or answers `undefined` when there is no such file. */ +export type SourceReader = (path: string) => string | undefined; + +/** + * The repository-relative file a LOCAL specifier names, from the importing + * file's own directory - or `undefined` for a package, a built-in, or a + * specifier that names no file in the tree. + */ +export const resolveLocalImport = ( + importer: string, + specifier: string, + read: SourceReader +): string | undefined => { + if (!specifier.startsWith("./") && !specifier.startsWith("../")) { + return undefined; + } + const base = normalize(join(dirname(importer), specifier)); + const candidates = [base, `${base}.ts`, `${base}.tsx`, `${base}/index.ts`]; + return candidates.find((candidate) => read(candidate) !== undefined); +}; + +/** + * A check's inputs: its entry, every local module the entry reaches through + * its imports, and its data inputs - minus the generated files, which are + * neither walked nor counted. A reached file that is not module source (a + * JSON import) counts as an input and is not walked further. Sorted, + * repository-relative. + * + * Throws when the entry cannot be read: an empty walk would answer "touches + * nothing", which reports a change that should have gated. Throws, naming the + * file, when a reached module cannot be stripped (`importSpecifiers`). + */ +export const inputsOf = ( + check: FreshnessCheck, + read: SourceReader +): string[] => { + const spec = FRESHNESS_CHECKS[check]; + if (read(spec.entry) === undefined) { + throw new Error( + `${check}: cannot read ${spec.entry} - the walk read nothing, which is not the same as no inputs` + ); + } + const generated = new Set(GENERATED_OUTPUTS); + const seen = new Set(); + const pending = [spec.entry]; + while (pending.length > 0) { + const file = pending.pop(); + if (file === undefined || seen.has(file) || generated.has(file)) { + continue; + } + seen.add(file); + if (!MODULE_EXTENSION.test(file)) { + continue; + } + let specifiers: string[]; + try { + specifiers = importSpecifiers(read(file) ?? ""); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${file}: ${message}`, { cause: error }); + } + for (const specifier of specifiers) { + const target = resolveLocalImport(file, specifier, read); + if (target !== undefined) { + pending.push(target); + } + } + } + for (const data of spec.dataInputs) { + if (!generated.has(data)) { + seen.add(data); + } + } + return [...seen].toSorted(); +}; + +/** One changed-file list, as `git diff --name-only` prints it. */ +export const parseChangedFiles = (text: string): string[] => + text + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line !== ""); + +/** GATE when the diff touches an input, REPORT otherwise. */ +export const scopeFor = ( + changedFiles: readonly string[], + inputs: readonly string[] +): FreshnessScope => { + const wanted = new Set(inputs); + return changedFiles.some((file) => wanted.has(file)) ? "gate" : "report"; +}; + +/** + * The check's generated files the diff changed while touching none of its + * inputs - a refresh, or a hand-edit; either way worth naming. Empty when the + * check gates: a generator change is expected to carry its regenerated output. + */ +export const handEditedOutputs = ( + check: FreshnessCheck, + changedFiles: readonly string[], + inputs: readonly string[] +): string[] => { + if (scopeFor(changedFiles, inputs) === "gate") { + return []; + } + const changed = new Set(changedFiles); + return FRESHNESS_CHECKS[check].outputs.filter((output) => + changed.has(output) + ); +}; + +/** The GitHub annotation that names one such file. */ +export const handEditWarning = ( + check: FreshnessCheck, + output: string +): string => + `::warning file=${output}::${output} changed, but nothing that generates it did (${FRESHNESS_CHECKS[check].entry} and its imports). It is generated: a refresh carries it, a hand-edit is forbidden (AGENTS.md). Regenerate with 'pnpm run ${check} -- --write' if this was not one.`; + +/** Reads a repository-relative path under `root`, or `undefined`. */ +const fileReader = + (root: string): SourceReader => + (path: string): string | undefined => { + try { + return readFileSync(resolve(root, path), "utf8"); + } catch { + return undefined; + } + }; + +const main = (): void => { + const check = process.argv[2] ?? ""; + if (!isFreshnessCheck(check)) { + process.stderr.write( + `usage: freshness-scope.ts <${Object.keys(FRESHNESS_CHECKS).join("|")}> < changed-files\n` + ); + process.exit(2); + } + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const changed = parseChangedFiles(readFileSync(0, "utf8")); + const inputs = inputsOf(check, fileReader(root)); + for (const output of handEditedOutputs(check, changed, inputs)) { + process.stderr.write(`${handEditWarning(check, output)}\n`); + } + process.stdout.write(`${scopeFor(changed, inputs)}\n`); +}; + +/** The real path of `path`, or `undefined` when there is none to resolve. */ +const realPath = (path: string | undefined): string | undefined => { + if (path === undefined) { + return undefined; + } + try { + return realpathSync(path); + } catch { + return undefined; + } +}; + +// Run only as a script, never on import (the tests import the functions above). +// Both sides are real paths: Node realpaths the main module's URL but only +// resolves `argv[1]`, so a plain comparison misses an absolute invocation +// through a symlink, and the CLI would print nothing and exit 0. +const invoked = realPath(process.argv[1]); +if ( + invoked !== undefined && + invoked === realPath(fileURLToPath(import.meta.url)) +) { + main(); +} diff --git a/scripts/generate-go-limits.ts b/scripts/generate-go-limits.ts index 490db224..7803bdbd 100644 --- a/scripts/generate-go-limits.ts +++ b/scripts/generate-go-limits.ts @@ -14,7 +14,10 @@ * A hand-typed table would therefore be stale within a release — and a stale * dollar figure is worse than none, because it looks authoritative. So the table * is generated from `https://opencode.ai/docs/go.md` (the page's own markdown - * source, served as `text/plain`) and CI fails when it drifts. + * source, served as `text/plain`), and CI checks it for drift: a pull request + * that changes this generator (or a module it loads) fails on a stale table; + * any other pull request, and a push to main, warns, and the nightly run + * proposes the refresh. * * **That URL is gone.** Measured 2026-10-12: it answers 404, the docs live under * `/v2/docs/console/go/` now, and the new page serves HTML - the allowance figures diff --git a/src/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/test/freshness-scope.test.ts b/test/freshness-scope.test.ts new file mode 100644 index 00000000..4a06f9bc --- /dev/null +++ b/test/freshness-scope.test.ts @@ -0,0 +1,928 @@ +/** + * Which freshness checks a pull request is gated on (`scripts/freshness-scope.ts`), + * and the CI wiring that acts on the answer. + * + * Three layers, each performed rather than asserted as a constant: the decision + * for each check on the three kinds of diff that matter (a generator change, a + * refresh, an unrelated change); the import walk over the REAL generator + * sources, read from disk, with a case whose answer is already known; and the + * workflow, parsed as data, with every step condition EVALUATED for each event + * and each scope rather than matched as text. + * + * @module test/freshness-scope.test + */ +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +import { + FRESHNESS_CHECKS, + type FreshnessCheck, + GENERATED_OUTPUTS, + handEditedOutputs, + handEditWarning, + importSpecifiers, + inputsOf, + isFreshnessCheck, + parseChangedFiles, + resolveLocalImport, + scopeFor, + type SourceReader, +} from "../scripts/freshness-scope.ts"; + +const ROOT = join(import.meta.dirname, ".."); + +/** Every path the disk reader was asked for, so a case can prove it read. */ +const diskReader = (): { read: SourceReader; asked: string[] } => { + const asked: string[] = []; + const read: SourceReader = (path: string): string | undefined => { + asked.push(path); + try { + return readFileSync(join(ROOT, path), "utf8"); + } catch { + return undefined; + } + }; + return { asked, read }; +}; + +/** A reader over an in-memory tree, so a case can move a generator's imports. */ +const treeReader = + (tree: Readonly>): SourceReader => + (path) => + tree[path]; + +const CHECKS = Object.keys(FRESHNESS_CHECKS).filter(isFreshnessCheck); + +/** + * The files commit 2ebaffc changed: a refresh of both generated files, plus the + * tests and docs that follow the vendor's rename. It is the case that turned + * every open pull request red, so it is the case that must now report. + */ +const REFRESH_ONLY = [ + "README.md", + "README.zh-CN.md", + "docs/redirect-planes.md", + "src/catalog-data.ts", + "src/go-limits-data.ts", + "test/attachment-services.test.ts", + "test/catalog.test.ts", + "test/go-google-prepare.test.ts", + "test/go-google.test.ts", + "test/models-discovery.test.ts", + "test/responses-routes.test.ts", + "test/usage-panel.test.tsx", +]; + +const UNRELATED = ["src/usage-pill.tsx", "test/usage-pill-mount.test.tsx"]; + +describe("freshness scope · the walk reads the real generators", () => { + it("finds the catalog parser behind the catalog generator, and proves it read the entry", () => { + const { asked, read } = diskReader(); + const inputs = inputsOf("catalog:shim", read); + // Known answers: the generator itself, the parser it calls, and what the + // parser imports in turn - one level down is what makes it transitive. + expect(inputs).toContain("scripts/regenerate-catalog-shim.ts"); + expect(inputs).toContain("src/models-catalog.ts"); + expect(inputs).toContain("src/utils/guards.ts"); + // `src/config.ts` is loaded (`DEFAULT_GATEWAY_URLS`), so a settings change + // gates this check; `src/session-cost.ts` is reached only through + // `import type`, which loads nothing, so it does not. + expect(inputs).toContain("src/config.ts"); + expect(importSpecifiers(read("src/models-catalog.ts") ?? "")).not.toContain( + "./session-cost.ts" + ); + expect(read("src/models-catalog.ts")).toMatch( + /^import type \{[^}]*\} from "\.\/session-cost\.ts";$/mu + ); + expect(inputs).not.toContain("src/session-cost.ts"); + expect(asked).toContain("scripts/regenerate-catalog-shim.ts"); + expect(asked).toContain("src/models-catalog.ts"); + }); + + it("finds the limits parser behind the limits generator", () => { + const { read } = diskReader(); + const inputs = inputsOf("limits:shim", read); + expect(inputs).toContain("scripts/generate-go-limits.ts"); + expect(inputs).toContain("src/go-limits.ts"); + // The generator's source holds `import type … from "./go-limits.ts"`, but + // as template text: the line it writes into the file it emits, not an + // import of the generator. The premise is checked first, so the second + // assertion has something to miss. (The line is also type-only; the + // known-tree case below separates template text from that on its own.) + const source = read("scripts/generate-go-limits.ts") ?? ""; + expect(source).toMatch( + /^import type \{[^}]*\} from "\.\/go-limits\.ts";$/mu + ); + expect(importSpecifiers(source)).not.toContain("./go-limits.ts"); + }); + + it("counts the pinned version's file as a CLI-version input, though nothing imports it", () => { + const { read } = diskReader(); + expect(inputsOf("cli:version", read)).toStrictEqual([ + "scripts/check-vendor-version.ts", + "src/identity.ts", + ]); + }); + + it("never counts a generated file as an input, though both generators import one", () => { + const { read } = diskReader(); + // The premise, checked on disk: each generator really does import a + // generated file, so the exclusion below is doing work. + expect( + importSpecifiers(read("scripts/regenerate-catalog-shim.ts") ?? "") + ).toContain("../src/catalog-data.ts"); + expect( + importSpecifiers(read("scripts/generate-go-limits.ts") ?? "") + ).toContain("../src/catalog-data.ts"); + for (const check of CHECKS) { + const inputs = inputsOf(check, read); + for (const output of GENERATED_OUTPUTS) { + expect(inputs, `${check} counts ${output}`).not.toContain(output); + } + } + }); + + it("moves the inputs when a generator gains an import", () => { + const { read } = diskReader(); + const entry = "scripts/generate-go-limits.ts"; + const before = inputsOf("limits:shim", read); + expect(before).not.toContain("src/utils/guards.ts"); + const grown: SourceReader = (path) => + path === entry + ? `import { isRecord } from "../src/utils/guards.ts";\n${read(path) ?? ""}` + : read(path); + expect(inputsOf("limits:shim", grown)).toStrictEqual( + [...before, "src/utils/guards.ts"].toSorted() + ); + }); + + it("agrees, on every real input, with an independent line-anchored reading of what Node keeps", () => { + // The walk reads edges with a lexer over Node's own stripped source. The + // oracle here is a different technique over the same stripped source: a + // module specifier on a line that STARTS an import or export statement (or + // closes a multi-line one). Where the two disagree on a local file, one of + // them is wrong - in either direction: an edge the walk dropped turns a + // gate into a report, and an edge it invented gates for nothing. + const { asked, read } = diskReader(); + const inputs = [ + ...new Set(CHECKS.flatMap((check) => inputsOf(check, read))), + ]; + expect(inputs.length).toBeGreaterThan(5); + expect(asked).toContain("src/models-catalog.ts"); + const entries = new Set( + CHECKS.flatMap((check) => FRESHNESS_CHECKS[check].dataInputs) + ); + const local = (importer: string, specifiers: string[]): string[] => + [ + ...new Set( + specifiers.flatMap((specifier) => { + const target = resolveLocalImport(importer, specifier, read); + return target === undefined ? [] : [target]; + }) + ), + ].toSorted(); + for (const file of inputs.filter((input) => !entries.has(input))) { + const source = read(file) ?? ""; + const lineAnchored = [ + ...stripTypeScriptTypes(source).matchAll( + /^\s*(?:(?:import|export)\b|\})[^\n]*?\bfrom\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']/gmu + ), + ].map((match) => match[1] ?? match[2] ?? ""); + expect( + local(file, importSpecifiers(source)), + `${file}: the walk's local edges against the line-anchored reading` + ).toStrictEqual(local(file, lineAnchored)); + } + }); + + it("refuses a walk that could not read its entry", () => { + expect(() => inputsOf("catalog:shim", treeReader({}))).toThrow( + /cannot read scripts\/regenerate-catalog-shim\.ts/u + ); + }); +}); + +describe("freshness scope · the import walk on a known tree", () => { + const tree = { + "scripts/regenerate-catalog-shim.ts": [ + '// import { notReal } from "../src/commented.ts";', + '/* import "../src/also-commented.ts"; */', + 'import { readFileSync } from "node:fs";', + 'import { a } from "../src/a.ts";', + 'import type { B } from "../src/b";', + 'export type { T } from "../src/types-only.ts";', + 'import { type K } from "../src/kept.ts";', + 'import "../src/side-effect.ts";', + 'const lazy = await import("../src/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"), + "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/lazy.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. + expect(inputsOf("catalog:shim", treeReader(tree))).toStrictEqual([ + "scripts/regenerate-catalog-shim.ts", + "src/a.ts", + "src/after-glob.ts", + "src/after-regex.ts", + "src/d.tsx", + "src/kept.ts", + "src/lazy.ts", + "src/nested/index.ts", + "src/side-effect.ts", + ]); + }); + + it("tells a type-only statement from a type alias and an inline type", () => { + expect( + importSpecifiers( + [ + 'import type { A } from "./a.ts";', + 'import type * as N from "./n.ts";', + 'import type D from "./d.ts";', + 'export type * from "./s.ts";', + // A default import NAMED `type` is a real one. + 'import type from "./t.ts";', + 'import { type K, v } from "./k.ts";', + // An alias without its semicolon must not swallow the next import. + "export type Alias = { a: string }", + 'import { c } from "./c.ts";', + ].join("\n") + ).toSorted() + ).toStrictEqual(["./c.ts", "./k.ts", "./t.ts"]); + }); + + it("neither counts a generated file nor walks into it", () => { + const inputs = inputsOf("catalog:shim", treeReader(tree)); + expect(inputs).not.toContain("src/catalog-data.ts"); + expect(inputs).not.toContain("src/only-via-output.ts"); + }); + + it("counts a JSON a generator imports as a leaf input, and does not lex it", () => { + // Node loads this import under the generators' own flag, so the walk must + // follow it - and must not hand JSON to the TypeScript stripper, which + // would throw. `{ "a": "./not-an-edge.ts" }` would be an edge if lexed. + const jsonTree = { + "package.json": '{ "version": "1.0.0", "a": "./not-an-edge.ts" }', + "not-an-edge.ts": "", + "scripts/check-vendor-version.ts": + 'import pkg from "../package.json" with { type: "json" };', + "src/identity.ts": "", + }; + expect(inputsOf("cli:version", treeReader(jsonTree))).toStrictEqual([ + "package.json", + "scripts/check-vendor-version.ts", + "src/identity.ts", + ]); + }); + + it("names the file when a reached module cannot be stripped", () => { + // An `enum` is what Node's strip mode refuses; the walk fails closed, and + // the message says where to look. + const enumTree = { + "scripts/regenerate-catalog-shim.ts": 'import { E } from "../src/e.ts";', + "src/e.ts": "export enum E { A }", + }; + expect(() => inputsOf("catalog:shim", treeReader(enumTree))).toThrow( + /^src\/e\.ts: /u + ); + }); + + it("resolves only local specifiers that name a file", () => { + const read = treeReader(tree); + expect(resolveLocalImport("src/a.ts", "pkg", read)).toBeUndefined(); + expect(resolveLocalImport("src/a.ts", "node:fs", read)).toBeUndefined(); + expect( + resolveLocalImport("src/a.ts", "./missing.ts", read) + ).toBeUndefined(); + expect(resolveLocalImport("src/a.ts", "./nested", read)).toBe( + "src/nested/index.ts" + ); + expect(resolveLocalImport("src/nested/index.ts", "../d", read)).toBe( + "src/d.tsx" + ); + }); +}); + +describe("freshness scope · the decision", () => { + const { read } = diskReader(); + const inputs = Object.fromEntries( + CHECKS.map((check) => [check, inputsOf(check, read)]) + ) as Record; + + const GENERATOR_CHANGE: Record = { + "catalog:shim": ["src/models-catalog.ts", "src/catalog-data.ts"], + "cli:version": ["src/identity.ts", "README.md"], + "limits:shim": ["scripts/generate-go-limits.ts", "src/go-limits-data.ts"], + }; + + for (const check of CHECKS) { + it(`${check}: a pull request touching its inputs gates`, () => { + expect(scopeFor(GENERATOR_CHANGE[check], inputs[check])).toBe("gate"); + // The entry alone is enough. + expect(scopeFor([FRESHNESS_CHECKS[check].entry], inputs[check])).toBe( + "gate" + ); + }); + + it(`${check}: a pull request carrying only a refresh reports`, () => { + expect(scopeFor(REFRESH_ONLY, inputs[check])).toBe("report"); + }); + + it(`${check}: an unrelated pull request reports, and so does an empty diff`, () => { + expect(scopeFor(UNRELATED, inputs[check])).toBe("report"); + expect(scopeFor([], inputs[check])).toBe("report"); + }); + } + + it("cli:version: a docs-only diff reports - its copies are gated by the separate offline step", () => { + // The registry half of cli:version follows the vendor; a stale COPY in a + // doc is the repository's own, and `cli:version -- --copies` gates every + // pull request on it (the wiring and the script cases below). + expect(scopeFor(["docs/deep-dive.md"], inputs["cli:version"])).toBe( + "report" + ); + }); + + it("gates one check without gating the others", () => { + const changed = ["src/go-limits.ts"]; + expect(scopeFor(changed, inputs["limits:shim"])).toBe("gate"); + expect(scopeFor(changed, inputs["catalog:shim"])).toBe("report"); + expect(scopeFor(changed, inputs["cli:version"])).toBe("report"); + }); + + it("names a generated file changed without its generator, and only then", () => { + expect( + handEditedOutputs("catalog:shim", REFRESH_ONLY, inputs["catalog:shim"]) + ).toStrictEqual(["src/catalog-data.ts"]); + expect( + handEditedOutputs("limits:shim", REFRESH_ONLY, inputs["limits:shim"]) + ).toStrictEqual(["src/go-limits-data.ts"]); + // A generator change is expected to carry its output: no warning. + expect( + handEditedOutputs( + "catalog:shim", + GENERATOR_CHANGE["catalog:shim"], + inputs["catalog:shim"] + ) + ).toStrictEqual([]); + expect( + handEditedOutputs("cli:version", REFRESH_ONLY, inputs["cli:version"]) + ).toStrictEqual([]); + expect( + handEditedOutputs("catalog:shim", UNRELATED, inputs["catalog:shim"]) + ).toStrictEqual([]); + }); + + it("writes the warning as an annotation on the file it names", () => { + expect(handEditWarning("catalog:shim", "src/catalog-data.ts")).toMatch( + /^::warning file=src\/catalog-data\.ts::src\/catalog-data\.ts changed.*pnpm run catalog:shim -- --write/u + ); + }); + + it("reads git's changed-file list, one path per line", () => { + expect(parseChangedFiles("a.ts\r\nb.ts\n\n c.ts \n")).toStrictEqual([ + "a.ts", + "b.ts", + "c.ts", + ]); + }); +}); + +/** A GitHub Actions `if:` expression, evaluated over the few names the job uses. */ +const evaluate = ( + expression: string, + names: Readonly> +): boolean | string => { + const tokens = + expression.match(/'[^']*'|==|!=|&&|\|\||[()!]|[A-Za-z_][\w.-]*/gu) ?? []; + const consumed = tokens.join(""); + if (consumed !== expression.replaceAll(/\s+/gu, "")) { + throw new Error(`unparsed text in: ${expression}`); + } + let at = 0; + const truthy = (value: boolean | string): boolean => + typeof value === "boolean" ? value : value !== ""; + const primary = (): boolean | string => { + const token = tokens[at]; + at += 1; + if (token === "(") { + const value = or(); + const closing = tokens[at]; + at += 1; + if (closing !== ")") throw new Error(`unbalanced: ${expression}`); + return value; + } + if (token === "!") return !truthy(primary()); + if (token?.startsWith("'")) return token.slice(1, -1); + if (token !== undefined && Object.hasOwn(names, token)) { + return names[token] ?? ""; + } + throw new Error(`unknown name ${token} in: ${expression}`); + }; + const comparison = (): boolean | string => { + const left = primary(); + const op = tokens[at]; + if (op === "==" || op === "!=") { + at += 1; + const right = primary(); + return op === "==" ? left === right : left !== right; + } + return left; + }; + const and = (): boolean | string => { + let value = comparison(); + while (tokens[at] === "&&") { + at += 1; + const right = comparison(); + value = truthy(value) && truthy(right); + } + return value; + }; + const or = (): boolean | string => { + let value = and(); + while (tokens[at] === "||") { + at += 1; + const right = and(); + value = truthy(value) || truthy(right); + } + return value; + }; + const result = or(); + if (at !== tokens.length) throw new Error(`trailing tokens: ${expression}`); + return truthy(result); +}; + +describe("freshness scope · the CI wiring, parsed", () => { + interface Step { + readonly id?: string; + readonly if?: string; + readonly name?: string; + readonly run?: string; + readonly uses?: string; + readonly with?: Record; + } + const workflow = parse( + readFileSync(join(ROOT, ".github", "workflows", "ci.yml"), "utf8") + ) as { jobs: Record }; + const steps = workflow.jobs.catalog?.steps ?? []; + + const scopeStep = steps.find((step) => step.id === "scope"); + /** `decide ` lines in the scope step: key -> check. */ + const keys = Object.fromEntries( + [...(scopeStep?.run ?? "").matchAll(/^\s*decide (\w+) (\S+)\s*$/gmu)].map( + (match) => [match[2], match[1]] + ) + ) as Record; + + /** + * Every step that runs one check in full: minus the nightly `--write` + * refresh, and minus the CLI version's offline `--copies` gate, which is + * pinned on its own below. + */ + const runsOf = (check: FreshnessCheck): Step[] => + steps.filter( + (step) => + step.run !== undefined && + new RegExp(`pnpm run ${check}(?! -- --(?:write|copies))\\b`, "u").test( + step.run + ) + ); + + const runs = ( + step: Step, + event: string, + outputs: Readonly> + ): boolean => + step.if === undefined + ? true + : evaluate(step.if, { + "github.event_name": event, + ...Object.fromEntries( + ["catalog", "limits", "cli"].map((key) => [ + `steps.scope.outputs.${key}`, + outputs[key] ?? "", + ]) + ), + }) === true; + + it("read the job: the checkout reaches the base, and the scope step decides each check", () => { + expect(steps.length).toBeGreaterThan(5); + const checkout = steps.find((step) => + step.uses?.startsWith("actions/checkout@") + ); + expect(checkout?.with?.["fetch-depth"]).toBe(2); + expect(scopeStep?.if).toBe("github.event_name == 'pull_request'"); + expect(scopeStep?.run).toContain("git diff --name-only HEAD^1 HEAD"); + expect(scopeStep?.run).toContain("scripts/freshness-scope.ts"); + expect(Object.keys(keys).toSorted()).toStrictEqual([...CHECKS].toSorted()); + }); + + /** + * One step's `run` text, executed the way the runner executes it (`bash -e`, + * GitHub's default shell, which has no pipefail), in the repository, with a + * shell function named in `prelude` standing in for a command the step calls. + */ + const execute = ( + run: string, + prelude: string, + env: Readonly> = {} + ): { + status: number | null; + stdout: string; + stderr: string; + output: string; + calls: string; + } => { + const temp = mkdtempSync(join(tmpdir(), "ci-step-")); + try { + const result = spawnSync("bash", ["-e", "-c", `${prelude}\n${run}`], { + cwd: ROOT, + encoding: "utf8", + env: { + ...process.env, + ...env, + CALLS: join(temp, "calls"), + GITHUB_OUTPUT: join(temp, "github-output"), + PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: temp, + }, + }); + const readOr = (path: string): string => { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } + }; + return { + calls: readOr(join(temp, "calls")), + output: readOr(join(temp, "github-output")), + status: result.status, + stderr: result.stderr, + stdout: result.stdout, + }; + } finally { + rmSync(temp, { force: true, recursive: true }); + } + }; + + /** `pnpm` as a function that records its arguments and fails, naming a cause. */ + const FAILING_PNPM = + 'pnpm() { echo "$*" >> "$CALLS"; echo "error: boom" >&2; return 1; }'; + const PASSING_PNPM = 'pnpm() { echo "$*" >> "$CALLS"; return 0; }'; + + /** Gate or report, by what the step DOES when the check fails. */ + const classify = (step: Step): "gate" | "report" => { + const failing = execute(step.run ?? "", FAILING_PNPM); + if (failing.status !== 0) { + return "gate"; + } + expect(failing.stdout, step.name).toMatch(/^::warning::/mu); + return "report"; + }; + + it("runs the scope step's own shell and the CLI it calls: a generator change gates, a refresh reports", () => { + expect(scopeStep?.run).toBeDefined(); + // `git` as a function: it records what the step asked for and answers the + // diff from a file, so the real CLI reads a known change list. + const scopeOf = (changed: readonly string[]) => { + const diff = join( + mkdtempSync(join(tmpdir(), "ci-diff-")), + "changed-files" + ); + writeFileSync(diff, changed.map((file) => `${file}\n`).join("")); + try { + return execute( + scopeStep?.run ?? "", + 'git() { echo "$*" >> "$CALLS"; cat "$FIXTURE_DIFF"; }', + { FIXTURE_DIFF: diff } + ); + } finally { + rmSync(dirname(diff), { force: true, recursive: true }); + } + }; + + const generator = scopeOf(["scripts/regenerate-catalog-shim.ts"]); + expect(generator.stderr).toBe(""); + expect(generator.status).toBe(0); + expect(generator.calls).toBe("diff --name-only HEAD^1 HEAD\n"); + expect(generator.output).toBe("catalog=gate\nlimits=report\ncli=report\n"); + // The output keys are the ones the `decide` lines name, in their order - + // and those are the keys the `if:` conditions read (the cases below). + expect( + [...generator.output.matchAll(/^(\w+)=/gmu)].map((match) => match[1]) + ).toStrictEqual(Object.values(keys)); + + const refresh = scopeOf(["src/catalog-data.ts", "src/go-limits-data.ts"]); + expect(refresh.status).toBe(0); + expect(refresh.output).toBe("catalog=report\nlimits=report\ncli=report\n"); + expect(refresh.stdout).toContain( + "::warning file=src/catalog-data.ts::src/catalog-data.ts changed" + ); + expect(refresh.stdout).toContain( + "::warning file=src/go-limits-data.ts::src/go-limits-data.ts changed" + ); + // The CLI's warnings are its own: Node's experimental notice is silenced. + expect(refresh.stdout).not.toContain("ExperimentalWarning"); + + const pin = scopeOf(["src/identity.ts"]); + expect(pin.output).toBe("catalog=report\nlimits=report\ncli=gate\n"); + }); + + it("the CLI refuses an unknown check with a usage line and exit 2", () => { + const run = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(ROOT, "scripts", "freshness-scope.ts"), + "catalog", + ], + { cwd: ROOT, encoding: "utf8", input: "src/catalog-data.ts\n" } + ); + expect(run.status).toBe(2); + expect(run.stdout).toBe(""); + expect(run.stderr).toMatch(/^usage: freshness-scope\.ts { + // Node realpaths the main module's URL but only resolves `argv[1]`; a + // run-as-script guard comparing the two printed nothing and exited 0. + const temp = mkdtempSync(join(tmpdir(), "freshness-link-")); + try { + const link = join(temp, "scripts"); + symlinkSync(join(ROOT, "scripts"), link, "dir"); + const run = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + join(link, "freshness-scope.ts"), + "catalog:shim", + ], + { + cwd: ROOT, + encoding: "utf8", + input: "scripts/regenerate-catalog-shim.ts\n", + } + ); + expect(run.stderr).toBe(""); + expect(run.stdout).toBe("gate\n"); + expect(run.status).toBe(0); + } finally { + rmSync(temp, { force: true, recursive: true }); + } + }); + + it("the scope step runs before every step that reads its outputs", () => { + const at = steps.findIndex((step) => step.id === "scope"); + expect(at).toBeGreaterThan(-1); + const readers = steps + .map((step, index) => ({ index, step })) + .filter(({ step }) => step.if?.includes("steps.scope.outputs.") === true); + expect(readers.length).toBeGreaterThanOrEqual(6); + for (const { index, step } of readers) { + expect(index, step.name).toBeGreaterThan(at); + } + }); + + for (const check of CHECKS) { + it(`${check}: a pull request runs exactly one of its gate and its report, gating unless the answer is "report"`, () => { + const key = keys[check] ?? ""; + const candidates = runsOf(check); + expect(candidates).toHaveLength(2); + // Performed: each step run with a failing check, and with a passing one. + const gates = candidates.filter((step) => classify(step) === "gate"); + const reports = candidates.filter((step) => classify(step) === "report"); + expect(gates, `${check} gate steps`).toHaveLength(1); + expect(reports, `${check} report steps`).toHaveLength(1); + const [gate] = gates; + const [report] = reports; + for (const step of [gate, report]) { + const passing = execute(step?.run ?? "", PASSING_PNPM); + expect(passing.status, step?.name).toBe(0); + expect(passing.stdout, step?.name).not.toMatch( + /::(?:warning|error)::/u + ); + expect(passing.calls.split("\n")[0], step?.name).toBe(`run ${check}`); + } + + // Fail closed: '' is a lost answer (a renamed output, a moved step). + const other = { catalog: "report", cli: "report", limits: "report" }; + for (const scope of ["", "gate", "report"]) { + const outputs = { ...other, [key]: scope }; + expect(runs(gate!, "pull_request", outputs), `gate on '${scope}'`).toBe( + scope !== "report" + ); + expect( + runs(report!, "pull_request", outputs), + `report on '${scope}'` + ).toBe(scope === "report"); + } + // The scope step does not run on a push: its outputs are empty there. + expect(runs(gate!, "push", {})).toBe(false); + expect(runs(report!, "push", {})).toBe(true); + // The nightly is unchanged: never gated; it reports the CLI version and + // regenerates the shims in its own step. + expect(runs(gate!, "schedule", {})).toBe(false); + expect(runs(report!, "schedule", {})).toBe(check === "cli:version"); + // A manual dispatch, as the job's comment says: no gate, and only the + // CLI version reports. + expect(runs(gate!, "workflow_dispatch", {})).toBe(false); + expect(runs(report!, "workflow_dispatch", {})).toBe( + check === "cli:version" + ); + }); + } + + it("cli:version's offline copy scan gates every pull request, whatever the scope", () => { + const copies = steps.filter((step) => + /pnpm run cli:version -- --copies\b/u.test(step.run ?? "") + ); + expect(copies).toHaveLength(1); + const [step] = copies; + // A gate: its failure fails the step. + expect(classify(step!)).toBe("gate"); + for (const scope of ["", "gate", "report"]) { + expect(runs(step!, "pull_request", { cli: scope }), scope).toBe(true); + } + // Elsewhere the full check's report step runs the scan too. + expect(runs(step!, "push", {})).toBe(false); + }); + + it("the CLI report step quotes the script's own cause, and needs its pipefail to", () => { + const report = runsOf("cli:version").find( + (step) => classify(step) === "report" + ); + const run = report?.run ?? ""; + const warned = execute(run, FAILING_PNPM); + expect(warned.status).toBe(0); + expect(warned.stdout).toMatch(/^::warning::error: boom$/mu); + // The control: the same step without its pipefail never warns, because + // `tee` answers for the pipeline. + const withoutPipefail = run.replace(/^\s*set -o pipefail\s*$/mu, ""); + expect(withoutPipefail).not.toBe(run); + const silent = execute(withoutPipefail, FAILING_PNPM); + expect(silent.status).toBe(0); + expect(silent.stdout).not.toContain("::warning::"); + }); + + it("each check's entry is the script package.json runs for it", () => { + const { scripts } = JSON.parse( + readFileSync(join(ROOT, "package.json"), "utf8") + ) as { scripts: Record }; + for (const check of CHECKS) { + expect(scripts[check]?.split(/\s+/u), check).toContain( + FRESHNESS_CHECKS[check].entry + ); + } + }); + + it("the evaluator answers the cases it is trusted with", () => { + expect( + evaluate("a == 'x' && (b == 'y' || !c)", { a: "x", b: "z", c: "" }) + ).toBe(true); + expect(evaluate("a != 'x'", { a: "x" })).toBe(false); + expect(() => evaluate("steps.scop.outputs.catalog == 'gate'", {})).toThrow( + /unknown name/u + ); + }); +}); + +describe("cli:version · the offline copy scan (`--copies`)", () => { + const SCRIPT = join(ROOT, "scripts", "check-vendor-version.ts"); + /** + * Where the pin lives, as freshness-scope counts it: the fixture writes it + * THERE, so a passing run proves the script reads the very file the scope + * treats as this check's data input, and moving one without the other fails. + */ + const PIN_FILE = FRESHNESS_CHECKS["cli:version"].dataInputs[0] ?? ""; + /** + * A version only the vendor's data says. Built from parts: the real copy + * scan reads this test file, so a spelled-out CLI literal here would fail + * the very gate it is testing. + */ + const VENDOR_ONLY = [9, 9, 9].join("."); + + /** + * A throwaway tree with a pin, one doc copy, and each generated table + * carrying a version that disagrees with the pin - vendor data, which the + * scan must skip - and the script run in it. + */ + const runIn = ( + pin: string, + copy: string, + args: readonly string[] + ): { status: number | null; stdout: string; stderr: string } => { + const root = mkdtempSync(join(tmpdir(), "cli-version-")); + try { + for (const [path, text] of Object.entries({ + "README.md": "# fixture\n", + "docs/x.md": `User-Agent: opencode/${copy}\n`, + [PIN_FILE]: `export const OPENCODE_CLI_VERSION = "${pin}";\n`, + ...Object.fromEntries( + GENERATED_OUTPUTS.map((output) => [ + output, + // Assembled, never spelled: this file is in the real scan's path. + `export const ROW = { ua: "${["opencode", VENDOR_ONLY].join("/")}", seen: "v${pin.split(".").slice(0, 2).join(".")}.99" };\n`, + ]) + ), + })) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); + } + const run = spawnSync( + process.execPath, + ["--experimental-strip-types", SCRIPT, ...args], + { cwd: root, encoding: "utf8" } + ); + return { status: run.status, stderr: run.stderr, stdout: run.stdout }; + } finally { + rmSync(root, { force: true, recursive: true }); + } + }; + + it("passes on this repository itself, as CI's pull-request gate will run it", () => { + // Offline, and the same scan CI gates every pull request on: a stale copy + // anywhere in the tree - this file included - fails here first. + const run = spawnSync( + process.execPath, + ["--experimental-strip-types", SCRIPT, "--copies"], + { cwd: ROOT, encoding: "utf8" } + ); + expect(run.stderr).toBe(""); + expect(run.status).toBe(0); + // A known answer: the tree carries copies, so the scan read something. + expect(run.stdout).toMatch(/^ok {3}[1-9]\d* copy\/copies /u); + }); + + it("fails on a copy that disagrees with the pin, naming the cause on its first line", () => { + const run = runIn("0.0.1", "0.0.0", ["--copies"]); + expect(run.status).toBe(1); + expect(run.stderr.split("\n")[0]).toBe( + `error: these copies do not say 0.0.1, which is what ${PIN_FILE} declares:` + ); + expect(run.stderr).toContain("0.0.0 in docs/x.md"); + // The generated tables' disagreeing versions are not reported as copies. + for (const output of GENERATED_OUTPUTS) { + expect(run.stderr).not.toContain(output); + } + }); + + it("passes when the copies agree, without asking the registry, and skips the generated tables", () => { + // 0.0.1 was never published, so a registry comparison would fail: the + // pass proves the scan stopped before it. Both generated tables say a + // disagreeing CLI literal and a bare `v0.0.99`: the pass proves the scan + // skipped them, because a vendor move cannot be a stale copy. + expect(PIN_FILE).not.toBe(""); + const run = runIn("0.0.1", "0.0.1", ["--copies"]); + expect(run.stderr).toBe(""); + expect(run.status).toBe(0); + expect(run.stdout).toMatch( + /^ok {3}1 copy\/copies .*say 0\.0\.1.*registry not asked/u + ); + }); +}); From d13cbe2410642254df0d0cf1392a5083386a0c6a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:31:26 +0000 Subject: [PATCH 15/17] chore: refresh the generated catalog after Zen retired deepseek-v4-flash The vendor retired `deepseek-v4-flash` from the Zen plane (it stays on Go). This pull request changes the catalog generator's own code, so under the freshness scoping it carries the refresh itself. This takes `src/catalog-data.ts` exactly as the nightly refresh produced it (#111), with no hand edits; `src/go-limits-data.ts` did not change. It then updates the counts that still hard-code the catalog's size: - the Zen shim count in catalog.test.ts (82 -> 81); - redirect-planes.md: Zen served 82 -> 81, the no-SDK Zen cell 25 -> 24, and "0 of 82" -> "0 of 81" in the prose; - protocol-routing-and-merge.md: the per-protocol shares now read "of 81", with completions at 24; - both READMEs: the no-SDK row is 24 and the active total is 81. The Chinese table had read 27 before this change, and now matches the English one. "127 listed" is unchanged, because the vendor retires a model by marking it `status: "deprecated"`, which keeps it listed. This could not be confirmed against models.dev, which cannot be reached from here. It no-ops once main carries #111. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- docs/protocol-routing-and-merge.md | 10 +++++----- docs/redirect-planes.md | 6 +++--- src/catalog-data.ts | 17 ----------------- test/catalog.test.ts | 2 +- 6 files changed, 13 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index abea5fd7..2c6e610c 100644 --- a/README.md +++ b/README.md @@ -159,13 +159,13 @@ Zen's provider-level SDK is `@ai-sdk/openai-compatible`. models.dev names a **di | models.dev `provider.npm` | models | protocol | served from | | :-- | --: | :-- | :-- | -| _(absent)_ | 25 | OpenAI Chat Completions | the route you configured | +| _(absent)_ | 24 | OpenAI Chat Completions | the route you configured | | `@ai-sdk/openai` | 31 | OpenAI Responses | `opencode-responses` | | `@ai-sdk/anthropic` | 17 | Anthropic Messages | `opencode-anthropic` | | `@ai-sdk/google` | 8 | Google Generative AI | `opencode-google` | | `@ai-sdk/mistral` | 1 | _(a dialect this plugin does not serve)_ | **not offered** | -Counts are the **82** active `opencode` models in `models.opencode.ai` as of 2026-10-23 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 82 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. +Counts are the **81** active `opencode` models in `models.opencode.ai` as of 2026-10-23 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 81 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. The patch **keeps those two routes out of both the model picker and _Settings → Models_**. Three properties hold today, and one does not yet: diff --git a/README.zh-CN.md b/README.zh-CN.md index baf73487..82eab11f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -157,13 +157,13 @@ Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在 | models.dev `provider.npm` | 模型数 | 协议 | 服务自 | | :-- | --: | :-- | :-- | -| _(缺失)_ | 27 | OpenAI Chat Completions | 你配置的路由 | +| _(缺失)_ | 24 | OpenAI Chat Completions | 你配置的路由 | | `@ai-sdk/openai` | 31 | OpenAI Responses | `opencode-responses` | | `@ai-sdk/anthropic` | 17 | Anthropic Messages | `opencode-anthropic` | | `@ai-sdk/google` | 8 | Google Generative AI | `opencode-google` | | `@ai-sdk/mistral` | 1 | _(本插件不服务的方言)_ | **不提供** | -数量为 `models.opencode.ai` 中 `opencode` 的 82 个**在用**模型,统计于 2026-10-23 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 82 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 +数量为 `models.opencode.ai` 中 `opencode` 的 81 个**在用**模型,统计于 2026-10-23 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 81 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 插件把这两条内部路由同时挡在模型选择器和 _Settings → Models_ 之外。**三条性质今天就成立,还有一条尚未成立:** diff --git a/docs/protocol-routing-and-merge.md b/docs/protocol-routing-and-merge.md index aa840375..8d7ad753 100644 --- a/docs/protocol-routing-and-merge.md +++ b/docs/protocol-routing-and-merge.md @@ -8,11 +8,11 @@ OpenCode serves its models on **four different API shapes**, and which one a mod | API shape | Wire endpoint | Which models | Signal in the catalog | | :-- | :-- | :-- | :-- | -| OpenAI **Chat Completions** | `POST /zen/v1/chat/completions` | **the default**: 25 of 82 | the model names **no** SDK | -| OpenAI **Responses** | `POST /zen/v1/responses` | 31 of 82 | `@ai-sdk/openai` | -| Anthropic **Messages** | `POST /zen/v1/messages` | 17 of 82 | `@ai-sdk/anthropic` | -| Google **Generative AI** | `POST /zen/v1/models/:streamGenerateContent` | 8 of 82 | `@ai-sdk/google` | -| Mistral **Conversations** | `POST /zen/v1/chat/completions` | 1 of 82 | `@ai-sdk/mistral` | +| OpenAI **Chat Completions** | `POST /zen/v1/chat/completions` | **the default**: 24 of 81 | the model names **no** SDK | +| OpenAI **Responses** | `POST /zen/v1/responses` | 31 of 81 | `@ai-sdk/openai` | +| Anthropic **Messages** | `POST /zen/v1/messages` | 17 of 81 | `@ai-sdk/anthropic` | +| Google **Generative AI** | `POST /zen/v1/models/:streamGenerateContent` | 8 of 81 | `@ai-sdk/google` | +| Mistral **Conversations** | `POST /zen/v1/chat/completions` | 1 of 81 | `@ai-sdk/mistral` | Counts are the shipped catalog. They are asserted against this table by `catalog.test.ts`, because a count written into prose does not regenerate with the data it counts. diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index b210b546..9437a600 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -10,7 +10,7 @@ A **plane** is one gateway base URL with its own catalog and its own credential. | Plane | Route id | Base URL | Credential | Models | | :-- | :-- | :-- | :-- | --: | -| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | `OPENCODE_API_KEY` | 82 served / 127 listed | +| Zen (pay-as-you-go, free tier) | `opencode` | the `endpoint` family's | `OPENCODE_API_KEY` | 81 served / 127 listed | | Go (subscription) | `opencode-go` | the `endpoint` family's | `OPENCODE_GO_API_KEY` | 30 served / 34 listed | **There is no longer one base URL per plane to quote.** The gateway answers on two endpoint families and the `endpoint` setting picks one: `auto` follows the catalog, which today names `…/inference/openai/v1` and `…/inference/go/openai/v1`, and `legacy` pins the older `…/zen/v1` and `…/zen/go/v1`. Both are still served and they are entitled separately, which is why the choice is the user's rather than a migration. The dated measurement below was taken on the legacy family. @@ -25,13 +25,13 @@ A **shape** is one wire protocol, named by the SDK a model declares in the catal | SDK in the catalog | Protocol | Zen | Go | | :------------------ | :---------------------- | --: | ----: | -| _(none)_ | `openai-completions` | 25 | 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 82 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. diff --git a/src/catalog-data.ts b/src/catalog-data.ts index 30904b62..a9285d67 100644 --- a/src/catalog-data.ts +++ b/src/catalog-data.ts @@ -869,23 +869,6 @@ export const OPENCODE_ZEN_CATALOG: readonly CatalogModelSpec[] = [ { type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }, ], }, - { - context_window: 1_000_000, - cost: { - cache_read: 0.028, - input: 0.14, - output: 0.28, - }, - id: "deepseek-v4-flash", - input_modalities: ["text"], - max_output_tokens: 384_000, - name: "DeepSeek V4 Flash", - reasoning: true, - reasoning_options: [ - { type: "toggle" }, - { type: "effort", values: ["low", "high", "max"] }, - ], - }, { context_window: 1_000_000, cost: { diff --git a/test/catalog.test.ts b/test/catalog.test.ts index 4224f9ea..5505c76d 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -197,7 +197,7 @@ describe("OpenCode Model Catalog & Enrichment", () => { // replacing the whole set — so these counts are the cold-start picker, and a // silent drop here is a model nobody can pick. Regenerate with // `scripts/regenerate-catalog-shim.ts` rather than editing by hand. - expect(OPENCODE_ZEN_CATALOG.length).toBe(82); + expect(OPENCODE_ZEN_CATALOG.length).toBe(81); const freeModels = OPENCODE_ZEN_CATALOG.filter((m) => m.is_free === true); // Only the free tiers the registry still lists as active. The count is the // `is_free` flag the shim carries, NOT the `-free` suffix: `big-pickle` is From 3d7cb38e0b1d3f44f720e3748762f1d4210038b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:35:44 +0000 Subject: [PATCH 16/17] test: give the freshness walk's dynamic import a script-local target The walk's fixture held the text `await import("../src/lazy.ts")`. The test tree's own architecture gate, which starts on the next branch of this stack, reads a dynamic `src` import anywhere in a test file's text, including inside a string. It counted that fixture line as a test bypassing `test/support/src.ts`, so every branch above this one failed the gate once this pull request was merged into it. The case is about the dynamic form, not about `src`, so the import now names `./lazy.ts` beside the script. The walk resolves it from the importing file's own directory, which is the same rule, and the expected inputs follow it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- test/freshness-scope.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/freshness-scope.test.ts b/test/freshness-scope.test.ts index 4a06f9bc..3ffbb871 100644 --- a/test/freshness-scope.test.ts +++ b/test/freshness-scope.test.ts @@ -226,7 +226,7 @@ describe("freshness scope · the import walk on a known tree", () => { 'export type { T } from "../src/types-only.ts";', 'import { type K } from "../src/kept.ts";', 'import "../src/side-effect.ts";', - 'const lazy = await import("../src/lazy.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 @@ -240,10 +240,10 @@ describe("freshness scope · the import walk on a known tree", () => { // 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/lazy.ts": "", "src/nested/index.ts": 'import { d } from "../d.tsx";', "src/d.tsx": "", "src/kept.ts": "", @@ -261,15 +261,16 @@ describe("freshness scope · the import walk on a known tree", () => { // 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. + // 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/lazy.ts", "src/nested/index.ts", "src/side-effect.ts", ]); From d16a600b2a9e595e2012e76e8ec67ae2d715227e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:12:21 +0000 Subject: [PATCH 17/17] docs: date the refreshed model count and correct three stale figures The README's 81-model count kept the date of the 82-model count it replaced (2026-10-23); it now carries the date of the refresh that produced it (2026-10-27). Also corrected, all present-tense statements the catalog had moved past: - both README install snippets named ^0.14.0 and ^0.12.0, ranges that cannot resolve to any 1.x release; both now name package.json's ^1.4.1; - redirect-planes.md said the Go plane serves 12 models on shapes other than completions; its own table gives 11 (30 less 19 with no SDK); - engineering-notes.md tied a general rule to "the 82" models the shim ships; it now says "the models it ships", which a refresh cannot strand. P-R1 carries the same corrections (dec00c8) with tests that hold the README range and the Go sentence to their sources. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- docs/engineering-notes.md | 2 +- docs/redirect-planes.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2c6e610c..a033a00b 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ The package is `dsh-opencode-patch` under every name it is used by: the dependen // ~/.dsh/profiles/web/package.json { "dependencies": { - "dsh-opencode-patch": "^0.14.0", + "dsh-opencode-patch": "^1.4.1", }, "dsh": { "profile": { @@ -165,7 +165,7 @@ Zen's provider-level SDK is `@ai-sdk/openai-compatible`. models.dev names a **di | `@ai-sdk/google` | 8 | Google Generative AI | `opencode-google` | | `@ai-sdk/mistral` | 1 | _(a dialect this plugin does not serve)_ | **not offered** | -Counts are the **81** active `opencode` models in `models.opencode.ai` as of 2026-10-23 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 81 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. +Counts are the **81** active `opencode` models in `models.opencode.ai` as of 2026-10-27 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 81 are bundled offline in `src/catalog-data.ts`, so every one of them routes correctly before the first catalog refresh; regenerate that file with `pnpm run catalog:shim`. The patch **keeps those two routes out of both the model picker and _Settings → Models_**. Three properties hold today, and one does not yet: diff --git a/README.zh-CN.md b/README.zh-CN.md index 82eab11f..875c6547 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -90,7 +90,7 @@ npm install dsh-opencode-patch // ~/.dsh/profiles/web/package.json { "dependencies": { - "dsh-opencode-patch": "^0.12.0", + "dsh-opencode-patch": "^1.4.1", }, "dsh": { "profile": { @@ -163,7 +163,7 @@ Zen 的 provider 级 SDK 是 `@ai-sdk/openai-compatible`。models.dev **只在 | `@ai-sdk/google` | 8 | Google Generative AI | `opencode-google` | | `@ai-sdk/mistral` | 1 | _(本插件不服务的方言)_ | **不提供** | -数量为 `models.opencode.ai` 中 `opencode` 的 81 个**在用**模型,统计于 2026-10-23 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 81 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 +数量为 `models.opencode.ai` 中 `opencode` 的 81 个**在用**模型,统计于 2026-10-27 —— 其余已废弃或下架,补丁只内置网关仍在服务的部分。同样这 81 个模型离线内置在 `src/catalog-data.ts`,因此在第一次 catalog 刷新之前它们每一个路由都是正确的;用 `pnpm run catalog:shim` 重新生成该文件。 插件把这两条内部路由同时挡在模型选择器和 _Settings → Models_ 之外。**三条性质今天就成立,还有一条尚未成立:** diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 14e9fed4..9147864f 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -94,7 +94,7 @@ So the fix belongs here rather than in anyone's profile: the routes for the othe **And the deeper half: a model the catalog does not carry cannot be routed at all.** Measured: `gemini-3.8-flash` and `claude-sonnet-4-6` are in neither shipped catalog, so `findModelSpecOn` finds no entry, `provider_npm` is `undefined`, and `undefined` is the DEFAULT — the completions shape. That default is right for a model that omits its SDK and wrong for one the catalog has never heard of, and the two are indistinguishable at that point. A model listed by hand on a completions route therefore stays there whatever protocol it actually speaks, and the gateway answers `400` — which is the whole of the reported symptom. -`catalog-data.ts` carries `provider_npm` only for models that override the default, so absence is meaningful for the 82 it ships and meaningless for anything else. A hand-listed model needs its protocol from somewhere: the catalog, a route that declares it, or a rule that reads it off the id. +`catalog-data.ts` carries `provider_npm` only for models that override the default, so absence is meaningful for the models it ships and meaningless for anything else. A hand-listed model needs its protocol from somewhere: the catalog, a route that declares it, or a rule that reads it off the id. **And the other half of I1: it is still OFFERED.** `models-discovery.ts` filters a route's listing with `isServableModel`, which asks the catalog for the model's SDK and treats a missing answer as servable — correctly, because absence means the completions default. For a model the catalog has never heard of, absence means the same thing by accident, so a hand-listed model is offered on a route that cannot serve it. The routing fix above moves it when it is chosen; this is the half that would stop it being offered at all, and the two belong together: I1 says a model that cannot work is not offered, and today that clause holds only for models the catalog describes. diff --git a/docs/redirect-planes.md b/docs/redirect-planes.md index 9437a600..a7ed34cb 100644 --- a/docs/redirect-planes.md +++ b/docs/redirect-planes.md @@ -35,7 +35,7 @@ A **shape** is one wire protocol, named by the SDK a model declares in the catal 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