From a03423e40616dc604676c7fa4085269601c3aa7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:24:31 +0000 Subject: [PATCH 01/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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 cd3107e6f49a819c83efd6c18d00decc3153d292 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 09:47:51 +0000 Subject: [PATCH 11/42] fix: repair what shipped broken in 1.4.1 - The deprecated @viztor/dsh-opencode wrapper re-exported a `default` the barrel does not have, an ESM link error on every host load. It now forwards `export *` only, written by one shared helper that a test links against the built barrel in a real Node process. - isZenProvider("opencode-google") answered false: "go" inside "google" read as the Go plane. It now uses the plane rule the catalog already has. - enrichModelsResponse answered a 401, 403, 429 or 5xx with a 200 catalog. Those statuses now pass through; a 404 is still filled from the catalog, because the Go plane's inference base has no /models of its own. - The warning colour named a token the host does not define, so warnings rendered as caption text. They now use --dsw-alias-state-warn-label. - The settings spinner only animated while the meter was mounted; the settings surface now carries its own keyframes. - The stream hook wrote debugFile with debug off. debug is the switch and debugFile only where it writes, the same gate the fetch patch uses. - The purge row reported the host's own { ok: false } failure as "not a purge result" and cast the answer; it now shows the host's message and parses through the contract's whitelisting parser. - The endpoint copy described modern as /inference/v1, which the plugin never sends. It now names the per-protocol inference family, and auto as its alias, which is what the code already did. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141fKp5k6hiaJcP5ovhGtSD --- README.md | 8 +- README.zh-CN.md | 8 +- cordis.patch.yml | 2 +- scripts/publish-scoped.ts | 12 +-- scripts/wrapper-forwarder.ts | 28 ++++++ src/config-values.ts | 8 +- src/config.ts | 23 +++-- src/google-bridge.ts | 13 +-- src/models-catalog.ts | 13 ++- src/pi-provider.ts | 18 ++-- src/settings-card.tsx | 2 +- src/settings-copy.ts | 12 +-- src/settings-purge.ts | 25 ++++- src/settings-store-inspect.tsx | 2 +- src/settings-usage.tsx | 18 +++- src/stream-hook.ts | 20 ++-- src/utils/request-shape.ts | 12 ++- test/catalog.test.ts | 68 ++++++++++++++ test/config.test.ts | 4 +- test/endpoint-copy.test.ts | 72 ++++++++++++++ test/host-tokens.test.ts | 100 ++++++++++++++++++++ test/lifecycle.test.ts | 2 +- test/pi-provider.test.ts | 22 ++++- test/request-shape.test.ts | 38 ++++++++ test/settings-card-purge.test.tsx | 30 ++++++ test/settings-purge.test.ts | 55 +++++++++++ test/settings-store-inspect.test.tsx | 46 +++++++++ test/settings-usage.test.tsx | 65 +++++++++++-- test/stream-hook.test.ts | 95 ++++++++++++++++++- test/usage-service.test.ts | 12 +++ test/wrapper-forwarder.test.ts | 136 +++++++++++++++++++++++++++ 31 files changed, 878 insertions(+), 91 deletions(-) create mode 100644 scripts/wrapper-forwarder.ts create mode 100644 test/endpoint-copy.test.ts create mode 100644 test/host-tokens.test.ts create mode 100644 test/wrapper-forwarder.test.ts diff --git a/README.md b/README.md index 31d7f18c..9a944891 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Nine controls in four sections — the decisions a user actually makes. Everythi | **Models & Free Tier** | Enrich model metadata from the OpenCode catalog | `on` | Fills catalog context limits, modalities and thinking levels into model info, and lets the gateway `/models` listing carry the full catalog. Does **not** add models to the picker — the picker shows declared models only; use the card's **Check catalog** row to declare more | | **Models & Free Tier** | Inject Core Tools | `on` | Adds the `read` + `bash` schemas free-tier `/responses` bodies require | | **Quota Meter** | Enable Composer Usage Meter | `on` | Mounts the live meter in the composer dock — the Go quota ring, or the Zen session spend | -| **Account & Endpoint** | API Endpoint | `auto` | Which endpoint family requests go to: **Automatic** (follow the catalog) · **Legacy** (`zen/v1`) · **Modern** (`inference/v1`) | +| **Account & Endpoint** | API Endpoint | `auto` | Which endpoint family requests go to: **Automatic** (same as Modern) · **Legacy** (`zen/v1`, `zen/go/v1`) · **Modern** (per-protocol `inference/{openai,anthropic,google}/…`, `inference/go/…` on Go) | | **Account & Endpoint** | Credential Source | `auto` | Which key wins when several are known: **Automatic** · **Live request first** · **Declared key first** | | **Account & Endpoint** | Read the OpenCode installation's key | `off` | Lets the plugin read the credential OpenCode itself stored, as the LAST resort — off by default | @@ -257,7 +257,7 @@ Nine controls in four sections — the decisions a user actually makes. Everythi Two questions decide where a request goes, and the **Account & Endpoint** section is where you answer them. -**Which endpoint family.** The gateway answers on two families — the older `/zen/v1` and the newer `/inference/v1` — and they are entitled **separately**: an account that works on one is not guaranteed to work on the other. `auto` follows the gateway's own catalog, which today names the newer family, so a catalog move carries you along; `legacy` and `modern` pin one, which is what you want when your account only works on the older one, or when you are checking that the newer one does. +**Which endpoint family.** The gateway answers on two families — the older `/zen/v1` (`/zen/go/v1` on Go), one base for every protocol, and the newer per-protocol `inference` family (`/inference/openai/v1`, `/inference/anthropic/v1`, `/inference/google/v1beta`, with `/inference/go/…` on Go) — and they are entitled **separately**: an account that works on one is not guaranteed to work on the other. `modern` sends each protocol to its own inference path; `auto`, the default, is an alias of `modern`; `legacy` pins the older family, which is what you want when your account only works there. **Which credential wins.** `keySource` orders the sources the plugin can see — `auto` prefers a key you declared, then one captured from a live request, then the declared reference; `request` promotes a captured key first; `configured` does the reverse. Below it, `readOpencodeStore` lets the plugin read the credential OpenCode itself stored, as the **last** resort only. Both are `off`/`auto` by default, so nothing changes until you say so. @@ -372,7 +372,7 @@ These exist in the schema but render no control — each is a literal, a marker, | `freeModelMarker` | `free` | Model-id substring; `*` forces the fallback, `''` disables it | | `usageBaseURL` | `https://opencode.ai/zen/go/v1` | Endpoint override; auto-discovered from the composition | | `credentialOrg` | — | The org id an account credential belongs to, when the deployment supplies one out of band | -| `debug` / `debugFile` | `false` / — | Diagnostic JSONL logging, not a behaviour anyone tunes in the UI | +| `debug` / `debugFile` | `false` / — | Diagnostic JSONL logging, not a behaviour anyone tunes in the UI; the file is written only while `debug` is on | ```yaml # cordis.patch.yml — the plugin's row; every key is optional. @@ -401,7 +401,7 @@ These exist in the schema but render no control — each is a literal, a marker, enrichModels: true # merge catalog specs into model info + gateway /models listings usageEnabled: true # off = no meter and no price row # File-level only debug options: - debug: false + debug: false # debugFile is written only while this is true debugFile: "/tmp/dsh-opencode-debug.jsonl" ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index dde83f11..ea8e086f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -248,7 +248,7 @@ opencode-responses: | **模型与免费额度** | 自动补全核心工具(默认开启) | `on` | 补上免费层 `/responses` 请求体所需的 `read` + `bash` schema | | **配额计量** | 开启输入区用量计量(默认开启) | `on` | 在输入框停靠栏挂载实时计量表——Go 额度环,或 Zen 会话消耗 | | **配额计量** | 显示会话消耗与模型费率(默认开启) | `on` | 追加显示本会话累计花费与当前模型每百万 Token 费率 | -| **账户与端点** | API 端点 | `auto` | 请求发往哪个端点家族:**自动**(跟随目录) · **旧端点**(`zen/v1`) · **新端点**(`inference/v1`) | +| **账户与端点** | API 端点 | `auto` | 请求发往哪个端点家族:**自动**(同新端点) · **旧端点**(`zen/v1`、`zen/go/v1`) · **新端点**(按协议的 `inference/{openai,anthropic,google}/…`,Go 为 `inference/go/…`) | | **账户与端点** | 凭据来源 | `auto` | 多个密钥同时可用时以哪个为准:**自动** · **优先实时请求** · **优先已声明密钥** | | **账户与端点** | 读取 OpenCode 安装版的密钥(默认关闭) | `off` | 允许本插件读取 OpenCode 自己保存的凭据,且仅作最后手段——默认关闭 | @@ -256,7 +256,7 @@ opencode-responses: 一个请求发往哪里由两个问题决定,答案就在**账户与端点**分区里。 -**发往哪个端点家族。** 网关在两套端点上都应答——较早的 `/zen/v1` 与较新的 `/inference/v1`——而两者的授权是**彼此独立**的:在一边可用的账户不保证在另一边可用。`auto` 会跟随网关自己的目录,目前它指向较新的一套,因此目录迁移会带着你一起走;`legacy` 与 `modern` 则固定其一,适用于账户只在较早一套上可用,或你想确认较新的一套是否可用的情况。 +**发往哪个端点家族。** 网关在两套端点上都应答——较早的 `/zen/v1`(Go 为 `/zen/go/v1`),所有协议共用一个基址;以及较新的按协议划分的 `inference` 家族(`/inference/openai/v1`、`/inference/anthropic/v1`、`/inference/google/v1beta`,Go 为 `/inference/go/…`)——而两者的授权是**彼此独立**的:在一边可用的账户不保证在另一边可用。`modern` 将每个协议发往各自的 inference 路径;`auto`(默认值)是 `modern` 的别名;`legacy` 固定使用较早的一套,适用于账户只在那里可用的情况。 **以哪个凭据为准。** `keySource` 决定插件可见的多个凭据源的优先级——`auto` 优先使用你声明过的密钥,其次是从实时请求中捕获的密钥,最后是声明的凭据引用;`request` 把捕获到的密钥提到最前;`configured` 则相反。其下的 `readOpencodeStore` 允许插件读取 OpenCode 自己保存的凭据,且**仅作最后手段**。两者默认分别为 `auto` 与关闭,因此在你说要改之前一切照旧。 @@ -362,7 +362,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 | `sessionIdEnv` | `OPENCODE_SESSION_ID` | 指定一个仅当调用方提供会话 id 时才使用的环境变量名 | | `freeModelMarker` | `free` | 模型 id 子串;`*` 强制启用回退,`''` 关闭回退 | | `usageBaseURL` | `https://opencode.ai/zen/go/v1` | 端点覆盖;从组合中自动发现 | -| `debug` / `debugFile` | `false` / — | 诊断用 JSONL 日志,不是谁会在 UI 里调节的行为 | +| `debug` / `debugFile` | `false` / — | 诊断用 JSONL 日志,不是谁会在 UI 里调节的行为;仅在 `debug` 开启时才写入该文件 | ```yaml # cordis.patch.yml — the plugin's row; every key is optional. @@ -390,7 +390,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 enrichModels: true # merge catalog specs into model info + gateway /models listings usageEnabled: true # off = no meter and no price row # File-level only debug options: - debug: false + debug: false # 仅在 debug 开启时写入 debugFile debugFile: "/tmp/dsh-opencode-debug.jsonl" ``` diff --git a/cordis.patch.yml b/cordis.patch.yml index 75beab27..7e1e7ae7 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -38,7 +38,7 @@ # families: ['opencode.ai/zen', 'opencode.ai/inference']). # usageBaseURL: string OpenCode Go gateway base URL (default 'https://opencode.ai/zen/go/v1'). # debug: true|false log every streamed call that receives the header. -# debugFile: path optional append target for stream debug JSONL. +# debugFile: path optional append target for stream debug JSONL; written only while debug is on. - insert: - id: dsh-opencode-patch name: "dsh-opencode-patch" diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index 072536b8..23e4ab15 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -37,6 +37,8 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; +import { writeWrapperEntries } from "./wrapper-forwarder.ts"; + const ROOT = path.resolve(import.meta.dirname, ".."); const SCOPED_TARGETS = ["@viztor/dsh-opencode-patch", "@viztor/dsh-opencode"]; @@ -132,13 +134,9 @@ for (const target of SCOPED_TARGETS) { "dsh-opencode-patch": `^${version}`, }; - // Thin wrapper entrypoints that re-export dsh-opencode-patch directly - const forwarder = [ - 'export * from "dsh-opencode-patch";', - 'export { default } from "dsh-opencode-patch";', - ].join("\n"); - writeFileSync(path.join(scratch, "lib", "index.mjs"), `${forwarder}\n`); - writeFileSync(path.join(scratch, "lib", "index.d.mts"), `${forwarder}\n`); + // Thin wrapper entrypoints: `export *` only — the barrel has no default + // export, and re-exporting one is a link error on every host load. + writeWrapperEntries(path.join(scratch, "lib")); // The wrapper carries no client half of its own. // diff --git a/scripts/wrapper-forwarder.ts b/scripts/wrapper-forwarder.ts new file mode 100644 index 00000000..627f241a --- /dev/null +++ b/scripts/wrapper-forwarder.ts @@ -0,0 +1,28 @@ +/** + * The entry the deprecated `@viztor/dsh-opencode` wrapper ships. + * + * Both its `lib/index.mjs` and its `lib/index.d.mts` are exactly these bytes: + * `export *` from the canonical package and nothing else. The barrel + * (`src/index.ts`) has named exports only, so a `default` re-export is an ESM + * link error the moment the host loads the wrapper — `SyntaxError: The + * requested module 'dsh-opencode-patch' does not provide an export named + * 'default'`. The text and the write both live here, so + * `test/wrapper-forwarder.test.ts` links what `publish-scoped.ts` writes by + * calling the same function, instead of linking a copy of it. + * + * @module scripts/wrapper-forwarder + */ +import { writeFileSync } from "node:fs"; +import path from "node:path"; + +export const WRAPPER_FORWARDER = 'export * from "dsh-opencode-patch";\n'; + +/** + * Write the wrapper's two entry files into a `lib/` directory. + * + * @param libDir - the wrapper package's `lib/` directory. + */ +export const writeWrapperEntries = (libDir: string): void => { + writeFileSync(path.join(libDir, "index.mjs"), WRAPPER_FORWARDER); + writeFileSync(path.join(libDir, "index.d.mts"), WRAPPER_FORWARDER); +}; diff --git a/src/config-values.ts b/src/config-values.ts index 2d14784b..c0093149 100644 --- a/src/config-values.ts +++ b/src/config-values.ts @@ -76,10 +76,10 @@ export const isKeySourcePolicy = (value: unknown): value is KeySourcePolicy => * The gateway answers on two families and the user, not the catalog, is the one * who knows which their account is entitled to: * - * - `auto` follows the catalog — the `api` the vendor's own entry names, which - * today is the `inference` family. - * - `legacy` forces the older `/zen/v1` pair. - * - `modern` forces the newer `/inference/v1` pair. + * - `auto` — an alias of `modern` (the default). + * - `legacy` — the older single base per plane, `/zen/v1` and `/zen/go/v1`. + * - `modern` — the per-protocol `inference` family, + * `/inference/{openai,anthropic,google}/…` and `/inference/go/…` on Go. * * Declared here rather than in `config.ts` so the client card can read the same * list without importing schemastery. diff --git a/src/config.ts b/src/config.ts index f690675c..e7ac0704 100644 --- a/src/config.ts +++ b/src/config.ts @@ -214,8 +214,8 @@ export interface ResolvedPluginConfig { * Which endpoint family the gateway's requests go to. * * The two families are independently entitled and both are still served, so - * the choice is the user's: `auto` follows the catalog, and `legacy` / - * `modern` pin one. See {@link ENDPOINT_FAMILIES}. + * the choice is the user's: `auto` is an alias of `modern` (the default), and + * `legacy` pins the older single base per plane. See {@link ENDPOINT_FAMILIES}. */ endpoint: EndpointFamily; /** `*` when the core-tool fallback should run for every model. */ @@ -276,8 +276,8 @@ const resolvedKeySource = (raw: unknown): KeySourcePolicy => { * A row's endpoint family, or the default when it names something else. * * Lenient for the same reason {@link resolvedKeySource} is: a typo in a - * hand-edited row degrades to `auto`, which follows the catalog and is the - * answer that cannot be wrong about which family is current. + * hand-edited row degrades to `auto`, the default, so a typo cannot select a + * family the user never named. */ const resolvedEndpoint = (raw: unknown): EndpointFamily => { const value = readString(raw); @@ -563,10 +563,13 @@ export const Config = z.object({ .string() .default(CONFIG_DEFAULTS.endpoint) .description( - "Which endpoint family the gateway's requests go to. auto: follow the " + - "catalog's own address (today the inference family). legacy: force " + - "https://opencode.ai/zen/v1. modern: force " + - "https://opencode.ai/inference/v1. The two families are separately " + + "Which endpoint family the gateway's requests go to. modern: the " + + "per-protocol inference family, " + + "https://opencode.ai/inference/{openai,anthropic,google}/... on Zen " + + "and https://opencode.ai/inference/go/{openai,anthropic,google}/... " + + "on Go. auto: an alias of modern, and the default. legacy: the older " + + "single base per plane, https://opencode.ai/zen/v1 and " + + "https://opencode.ai/zen/go/v1. The two families are separately " + "entitled and both still answer, so this is a choice rather than a " + "migration; an unrecognised value falls back to auto." ) @@ -619,5 +622,7 @@ export const Config = z.object({ .description("Log injected streamed calls."), debugFile: z .string() - .description("Server-side path for JSONL stream-debug entries."), + .description( + "Server-side path for JSONL stream-debug entries; written only while debug is on." + ), }); diff --git a/src/google-bridge.ts b/src/google-bridge.ts index 1c9eb5a8..fc9385ba 100644 --- a/src/google-bridge.ts +++ b/src/google-bridge.ts @@ -357,8 +357,9 @@ const installedEntryFor = ( * one — a provider is a record pi dispatches through, not a class it * instantiates. `getModels` returns the models of the Zen plane whose SDK is * `@ai-sdk/google`, each carrying the base URL this protocol uses on that - * plane (`…/zen/v1`), so the request lands on - * `…/zen/v1/models/:streamGenerateContent`. + * plane (`…/inference/google/v1beta` under auto or modern, and + * `…/inference/go/google/v1beta` on Go), so the request lands on + * `…/inference/google/v1beta/models/:streamGenerateContent`. * * The streams are pi's raw implementation. The session, client, project and * User-Agent headers are NOT added here: the fetch patch already restores all @@ -366,8 +367,8 @@ const installedEntryFor = ( * append, so the bridge would only duplicate that work. * * @param specs - the catalogued models the route serves. - * @param family - the endpoint family to send them to; the catalog's own - * address by default. + * @param family - the endpoint family to send them to; auto (an alias of + * modern) by default. * @returns the provider the adapter's collection will hold. */ export const googleProviderFor = ( @@ -512,8 +513,8 @@ export interface GoogleBridgeInputs { */ readonly readOpencodeStore?: boolean; /** - * Which endpoint family the bridge's models are sent to. The catalog's own - * address by default, and the same choice the internal routes use so a + * Which endpoint family the bridge's models are sent to. Auto (an alias of + * modern) by default, and the same choice the internal routes use so a * deployment cannot end up on two families at once. */ readonly endpoint?: EndpointFamily; diff --git a/src/models-catalog.ts b/src/models-catalog.ts index 642de652..5a263acb 100644 --- a/src/models-catalog.ts +++ b/src/models-catalog.ts @@ -695,6 +695,14 @@ export const isGoModelsListingUrl = ( /** * Merge and enrich a gateway `/models` response with the complete OpenCode Go or Zen catalog. * + * Passes any non-2xx other than a 404 through unchanged: a 401, a 403, a 429 or + * a 5xx is the gateway's verdict on the request — a missing or refused key, a + * tier the account lacks, a rate limit, an outage — and answering it with a + * catalog told the caller the listing had succeeded. A 404 is filled on both + * planes with that plane's catalog: the Go plane's inference base has no + * `/models` of its own, so the plane's catalog IS the listing there, and a Zen + * base without one is answered the same way. + * * @param url - the request URL * @param response - the upstream fetch Response * @param fetchFn - optional fetch implementation for SWR background refresh @@ -705,6 +713,9 @@ export const enrichModelsResponse = async ( response: Response, fetchFn: typeof fetch = globalThis.fetch ): Promise => { + if (!response.ok && response.status !== 404) { + return response; + } let existingData: Record[] = []; try { const raw: unknown = await response.clone().json(); @@ -712,7 +723,7 @@ export const enrichModelsResponse = async ( existingData = raw.data.filter(isRecord); } } catch { - // If the upstream returned non-JSON, generate the full catalog response. + // A 2xx or 404 whose body is not JSON still yields the full catalog. } // The plane from the PATH alone. The caller has already decided this is a diff --git a/src/pi-provider.ts b/src/pi-provider.ts index ff5fca4a..4bb0bca6 100644 --- a/src/pi-provider.ts +++ b/src/pi-provider.ts @@ -79,10 +79,10 @@ export const LEGACY_PLANE_API: Readonly> = { /** * The base URL one plane's requests go to under one endpoint family. * - * `auto` is the catalog's own value, so following the vendor costs nothing here; - * `legacy` and `modern` pin the family explicitly. The two families are - * independently entitled, which is why a user can choose rather than being - * carried along by whatever the catalog happens to name. + * `auto` is an alias of `modern`: both answer the per-protocol inference + * family's plane base, and `legacy` pins the older single base per plane. The + * two families are independently entitled, which is why a user can choose + * rather than being carried along by whatever the catalog happens to name. * * @param plane - the gateway plane. * @param family - the endpoint family to resolve. @@ -197,8 +197,8 @@ const INFERENCE_BASE: Readonly< * * Memoised per family: the table is rebuilt at most once per family per process, * and the map keys are exactly the accepted {@link EndpointFamily} values, so an - * unknown family falls through to the catalog's own answer rather than to - * `undefined`. + * unknown family falls through to auto's answer (an alias of modern) rather + * than to `undefined`. */ const BASES_BY_FAMILY = new Map(); @@ -244,7 +244,7 @@ function withPlaneBase( type BaseTable = Readonly>>>; /** - * The base URL each protocol uses on each plane, following the catalog. + * The base URL each protocol uses on each plane under auto, an alias of modern. * * Kept as the default table for callers that name no family; read * {@link baseForProtocol} with an explicit family once one is known. @@ -292,7 +292,7 @@ const isPlane = (value: string): value is Plane => * * @param plane - the gateway plane. * @param protocol - the protocol the request speaks. - * @param family - the endpoint family; the catalog's own endpoint by default. + * @param family - the endpoint family; auto (an alias of modern) by default. * @returns the base URL, or undefined when the table does not carry that plane. */ export const baseForProtocol = ( @@ -405,7 +405,7 @@ const ALL_THINKING_LEVELS = [ * @param spec - the catalogued model. * @param protocol - the protocol it speaks, from {@link protocolFor}. * @param base - pi's installed catalog entry for this id, when it has one. - * @param family - the endpoint family to send it to; the catalog's own endpoint + * @param family - the endpoint family to send it to; auto (an alias of modern) * by default. * @returns the model object pi's provider dispatches on. */ diff --git a/src/settings-card.tsx b/src/settings-card.tsx index 6927ac72..07d5a248 100644 --- a/src/settings-card.tsx +++ b/src/settings-card.tsx @@ -156,7 +156,7 @@ const PurgeRow: React.FC<{
= { groupQuota: "配额计量", groupRequests: "网关请求", endpoint: "API 端点", - endpointAuto: "自动(跟随目录)", + endpointAuto: "自动(同新端点)", endpointHint: - "请求发往哪个端点家族。自动会跟随网关目录给出的地址;另外两项则固定其一。旧端点与新端点的授权彼此独立且目前均可访问,因此请选择账户可用的那个,而不是当前最新的那个。", + "请求发往哪个端点家族。新端点按协议各自发往 inference 路径(inference/openai、inference/anthropic、inference/google;Go 为 inference/go/…);自动与新端点相同;旧端点固定使用较早的 zen/v1(Go 为 zen/go/v1)。两套端点的授权彼此独立且目前均可访问,因此请选择账户可用的那个。", endpointLegacy: "旧端点(zen/v1)", - endpointModern: "新端点(inference/v1)", + endpointModern: "新端点(inference,按协议)", freeModel: "免费", invalidText: "该值未被接受,留空使用默认值。", keySource: "凭据来源", diff --git a/src/settings-purge.ts b/src/settings-purge.ts index 7d582a33..31c098a5 100644 --- a/src/settings-purge.ts +++ b/src/settings-purge.ts @@ -10,7 +10,11 @@ * @module dsh-opencode-patch/settings-purge */ -import { PURGE_NAMESPACE, type PurgeResult } from "./purge-contract.ts"; +import { + PURGE_NAMESPACE, + parsePurgeResult, + type PurgeResult, +} from "./purge-contract.ts"; import { remoteNamespace, remoteNamespaceOf } from "./settings-remote.ts"; import { isRecord } from "./utils/guards.ts"; @@ -44,15 +48,28 @@ export const purgeDeprecatedModels = 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 refused the purge", + }; + } const value = isRecord(answer) && "value" in answer ? answer.value : answer; - if (!isRecord(value) || !Array.isArray(value.removed)) { + // The contract's own parser, so the card renders only the fields it whitelists. + const result = parsePurgeResult(value); + if (result === undefined) { return { kind: "failed", message: "the host answered something that is not a purge result", }; } - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the shape was just checked field by field. - return { kind: "purged", result: value as unknown as PurgeResult }; + return { kind: "purged", result }; } catch (error: unknown) { return { kind: "failed", diff --git a/src/settings-store-inspect.tsx b/src/settings-store-inspect.tsx index f35f82b9..2e529c44 100644 --- a/src/settings-store-inspect.tsx +++ b/src/settings-store-inspect.tsx @@ -247,7 +247,7 @@ export const StoreInspectRow: React.FC = (props) => {
+ {/* Component-local, so it leaves with the view — as the meter's does. */} +
{ if (typeof provider !== "string") { return false; } const lower = provider.toLowerCase(); - return lower.includes("opencode") && !lower.includes("go"); + if (!lower.includes("opencode")) { + return false; + } + return !(lower === "opencode-go" || lower.startsWith("opencode-go-")); }; diff --git a/test/catalog.test.ts b/test/catalog.test.ts index f55281a6..496169ee 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -380,6 +380,8 @@ describe("OpenCode Model Catalog & Enrichment", () => { // …and the Zen catalog's are not. expect(goJson.data.some((m) => m.id === "claude-opus-4-6")).toBe(false); expect(goJson.data.length).toBe(getLiveGoCatalog().length); + // The fill is the listing, so it answers as one. + expect(goResponse.status).toBe(200); }); it("keeps the Muse 1.2 rows the zen plane actually serves", async () => { @@ -871,6 +873,72 @@ describe("findModelSpec", () => { }); }); +describe("enrichModelsResponse: the gateway's verdict passes through", () => { + const url = "https://opencode.ai/zen/v1/models"; + + it("enrichModelsResponse: a 401, a 403, a 429 and a 5xx reach the caller as the gateway sent them", async () => { + // A refused key, a refused tier and an outage are answers about the + // REQUEST. Filling them with the catalog told discovery the listing had + // succeeded, and the reason the gateway gave never reached anyone. + const rows = [ + { + body: JSON.stringify({ error: { message: "Missing API key" } }), + status: 401, + type: "application/json", + }, + { + body: JSON.stringify({ error: { type: "FreeTierError" } }), + status: 403, + type: "application/json", + }, + // Not an auth answer and not an outage: the rule is "every non-2xx but + // 404", and this row records that a rate limit is the gateway's too. + { + body: JSON.stringify({ error: { type: "RateLimitError" } }), + headers: { "retry-after": "30" }, + status: 429, + type: "application/json", + }, + { body: "upstream down", status: 503, type: "text/plain" }, + ]; + for (const row of rows) { + const extra: Record = + "headers" in row && row.headers !== undefined ? row.headers : {}; + const upstream = new Response(row.body, { + headers: { "content-type": row.type, ...extra }, + status: row.status, + }); + const out = await enrichModelsResponse(url, upstream); + expect(out.status).toBe(row.status); + expect(await out.text()).toBe(row.body); + expect(out.headers.get("content-type")).toBe(row.type); + for (const [name, value] of Object.entries(extra)) { + expect(out.headers.get(name)).toBe(value); + } + } + }); + + it("fills a Zen-plane 404 with the Zen catalog, as it does the Go plane's", async () => { + const out = await enrichModelsResponse( + url, + new Response("Not Found", { status: 404 }) + ); + expect(out.status).toBe(200); + const body = (await out.json()) as { data: unknown[] }; + expect(body.data.length).toBe(getLiveZenCatalog().length); + }); + + it("still answers a 200 whose body is not JSON with the full catalog", async () => { + const out = await enrichModelsResponse( + url, + new Response("not json", { status: 200 }) + ); + expect(out.status).toBe(200); + const body = (await out.json()) as { data: unknown[] }; + expect(body.data.length).toBe(getLiveZenCatalog().length); + }); +}); + describe("enrichModelsResponse: a model neither source describes", () => { it("still describes it fully, so the picker never shows a row with no limits", async () => { // The gateway adds models before models.dev — or the bundled shim — knows diff --git a/test/config.test.ts b/test/config.test.ts index 6b2b1979..e4efd16d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -363,8 +363,8 @@ describe("Config schema", () => { it("accepts each endpoint family and degrades an unknown one to auto", () => { // Same rule as the key-source policy, for the same reason: the family is - // user-facing and hand-editable, and `auto` follows the catalog, so a typo - // degrades to the answer that cannot be wrong about which family is current. + // user-facing and hand-editable, so a typo degrades to `auto`, the default, + // rather than selecting a family the user never named. for (const family of ENDPOINT_FAMILIES) { expect(resolveConfig({ endpoint: family }).endpoint).toBe(family); } diff --git a/test/endpoint-copy.test.ts b/test/endpoint-copy.test.ts new file mode 100644 index 00000000..9388e11d --- /dev/null +++ b/test/endpoint-copy.test.ts @@ -0,0 +1,72 @@ +/** + * What the endpoint knob says it does, on every surface that says it. + * + * The schema description, the card's two locales and both READMEs described + * `modern` as `/inference/v1` — a base the code never sends. `modern` (and + * `auto`, which is the same table today) sends each protocol to its own + * inference path: `/inference/{openai,anthropic,google}/…`, under + * `/inference/go/…` on the Go plane. `legacy` is the older single base per + * plane. Copy is the only place a user learns that, so it is pinned. + * + * @module test/endpoint-copy.test + */ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { Config } from "../src/config.ts"; +import { en, zh } from "../src/settings-copy.ts"; + +/** The first line of a file that contains `marker`, or fail loudly. */ +const lineOf = async (file: string, marker: string): Promise => { + const text = await readFile(new URL(file, import.meta.url), "utf8"); + const line = text.split("\n").find((l) => l.includes(marker)); + if (line === undefined) { + throw new Error(`${file} has no line containing ${marker}`); + } + return line; +}; + +describe("endpoint copy", () => { + it("the endpoint copy names the per-protocol inference family and calls auto an alias of modern", async () => { + const schema = ( + Config as unknown as { + dict: { endpoint: { meta: { description: unknown } } }; + } + ).dict.endpoint.meta.description; + expect(typeof schema).toBe("string"); + const d = String(schema); + expect(d).not.toContain("inference/v1"); + expect(d).toContain("inference/{openai,anthropic,google}"); + expect(d).toContain("inference/go/{openai,anthropic,google}"); + expect(d).toMatch(/auto: an alias of modern/u); + + for (const copy of [en, zh]) { + const card = copy.endpointModern + copy.endpointAuto + copy.endpointHint; + expect(card).not.toContain("inference/v1"); + expect(card).toContain("inference/openai"); + // Legacy is one base per plane, and the Go plane's is not zen/v1. + expect(copy.endpointHint).toContain("zen/go/v1"); + } + expect(en.endpointAuto).toContain("same as Modern"); + expect(zh.endpointAuto).toContain("同新端点"); + + const row = await lineOf("../README.md", "API Endpoint"); + expect(row).not.toContain("inference/v1"); + expect(row).toContain("inference/{openai,anthropic,google}"); + expect(row).toContain("same as Modern"); + const zhRow = await lineOf("../README.zh-CN.md", "API 端点"); + expect(zhRow).not.toContain("inference/v1"); + expect(zhRow).toContain("inference/{openai,anthropic,google}"); + expect(zhRow).toContain("同新端点"); + + for (const [file, marker] of [ + ["../README.md", "**Which endpoint family.**"], + ["../README.zh-CN.md", "**发往哪个端点家族。**"], + ] as const) { + const paragraph = await lineOf(file, marker); + expect(paragraph, file).not.toContain("inference/v1"); + expect(paragraph, file).toContain("/inference/openai/v1"); + } + }); +}); diff --git a/test/host-tokens.test.ts b/test/host-tokens.test.ts new file mode 100644 index 00000000..741fe6bd --- /dev/null +++ b/test/host-tokens.test.ts @@ -0,0 +1,100 @@ +/** + * Every `--dsw-*` token the settings card names is one the host's design + * language carries. + * + * A `var(--…)` the host never declares fails silently: the property falls back + * to whatever it inherits, and a warning renders as ordinary text. That is how + * `--dsw-alias-label-warning` shipped — the name appears nowhere in the host's + * primitives, and its caption fallback hid that it resolved to nothing. + * + * A PROXY, stated as one: the tokens are DEFINED by the host theme + * (`@deepseek-ai/dsh-client-ui-theme`, `design-platform.css`), which is not + * installed here. The primitives' CSS modules are, and they USE the theme's + * tokens (AGENTS.md: "start from the host's built-ins"), so the check reads + * their usages. It can therefore fail if no primitive uses a token the theme + * still defines, or pass on a token some primitive names that the theme + * dropped. Point it at the theme's definitions once the theme is a + * devDependency. + * + * The scan covers the card and every file it renders that names a token (the + * store-inspect row, the usage summary, the field shell and the choice field); + * which token the mounted card renders for a warning is asserted in + * settings-card-purge and settings-store-inspect. The meter (`usage-ui.ts`, + * `usage-panel.tsx`) is left to later work: it builds `--dsw-alias-state-…` + * names from a template, which a plain scan would misread. + * + * @module test/host-tokens.test + */ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const PRIMITIVES = join( + ROOT, + "node_modules/@deepseek-ai/dsh-client-ui-primitives/lib" +); + +/** + * The card files whose tokens are checked against the host's set. The card + * itself stays first (the known-answer check below reads it); a file that + * names no token (`settings-boolean-field.tsx`) is not listed, since the scan + * treats an empty file as one it failed to read. + */ +const CARD_FILES = [ + "src/settings-card.tsx", + "src/settings-store-inspect.tsx", + "src/settings-usage.tsx", + "src/settings-field-shell.tsx", + "src/settings-choice-field.tsx", +]; + +const TOKEN = /--dsw-[\w-]+/gu; + +/** Every `--dsw-*` token the primitives' CSS modules mention. */ +const hostTokens = (): { files: string[]; tokens: Set } => { + const files = readdirSync(PRIMITIVES).filter((name) => + name.endsWith(".module.css") + ); + const tokens = new Set(); + for (const name of files) { + const css = readFileSync(join(PRIMITIVES, name), "utf8"); + for (const match of css.matchAll(TOKEN)) { + tokens.add(match[0]); + } + } + return { files, tokens }; +}; + +/** Every `--dsw-*` token a source file names. */ +const tokensIn = (file: string): Set => + new Set( + [...readFileSync(join(ROOT, file), "utf8").matchAll(TOKEN)].map( + (match) => match[0] + ) + ); + +describe("host tokens", () => { + it("every token the settings card names is one the host's primitives use", () => { + const { files, tokens } = hostTokens(); + // A read of a directory that moved finds nothing, and "nothing" must not + // read as "the token is absent". + expect(files.length).toBeGreaterThan(0); + // The known answer: the name that shipped is not a host token. + expect(tokens.has("--dsw-alias-label-warning")).toBe(false); + + for (const file of CARD_FILES) { + const named = tokensIn(file); + // A file that names no token at all is a scan that read nothing. + expect(named.size, file).toBeGreaterThan(0); + for (const token of named) { + expect(tokens.has(token), `${file} names ${token}`).toBe(true); + } + } + expect(tokensIn(CARD_FILES[0] ?? "")).toContain( + "--dsw-alias-state-warn-label" + ); + }); +}); diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 448fbae6..55dda10c 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -1097,7 +1097,7 @@ describe("apply (plugin lifecycle)", () => { streamHandler = handler; }, }; - apply(ctx, { debugFile }); + apply(ctx, { debug: true, debugFile }); if (typeof streamHandler !== "function") { throw new TypeError("stream handler not registered"); } diff --git a/test/pi-provider.test.ts b/test/pi-provider.test.ts index 0a822132..952462ef 100644 --- a/test/pi-provider.test.ts +++ b/test/pi-provider.test.ts @@ -13,6 +13,7 @@ import { BASE_FOR_PROTOCOL, baseForProtocol, piModelFor, + planeBaseFor, protocolFor, thinkingLevelMapFor, } from "../src/pi-provider.ts"; @@ -275,9 +276,9 @@ describe("pi-provider · the tables themselves", () => { } }); - it("resolves each endpoint family to its own base, and auto to the catalog", () => { - // `auto` is the catalog's own address and therefore cannot be wrong about - // which family is current; the other two pin one, which is the whole point + it("resolves each endpoint family to its own base, and auto as modern", () => { + // `auto` is an alias of `modern` (the copy says so, test/endpoint-copy); + // `legacy` pins the older single base per plane, which is the whole point // of the setting — the two families are separately entitled. expect(baseForProtocol("opencode", "openai-completions", "auto")).toBe( OPENCODE_PLANE_API.zen @@ -301,6 +302,19 @@ describe("pi-provider · the tables themselves", () => { ); }); + it("resolves auto exactly as modern on every protocol and plane", () => { + for (const plane of ["opencode", "opencode-go"] as const) { + expect(planeBaseFor(plane, "auto")).toBe(planeBaseFor(plane, "modern")); + const protocols = Object.keys(BASE_FOR_PROTOCOL[plane]); + expect(protocols.length).toBe(4); + for (const protocol of protocols) { + expect(baseForProtocol(plane, protocol, "auto"), protocol).toBe( + baseForProtocol(plane, protocol, "modern") + ); + } + } + }); + it("keeps the Anthropic base one level shorter on every family", () => { for (const family of ENDPOINT_FAMILIES) { for (const plane of ["opencode", "opencode-go"] as const) { @@ -333,7 +347,7 @@ describe("pi-provider · the tables themselves", () => { expect( piModelFor("opencode", model, "openai-completions", {}, "modern").baseUrl ).toBe(OPENCODE_PLANE_API.zen); - // No family named means the catalog's own address, as before. + // No family named means the default, auto, which resolves as modern. expect( piModelFor("opencode", model, "openai-completions", {}).baseUrl ).toBe(OPENCODE_PLANE_API.zen); diff --git a/test/request-shape.test.ts b/test/request-shape.test.ts index e2a14ed6..c663d3b9 100644 --- a/test/request-shape.test.ts +++ b/test/request-shape.test.ts @@ -9,17 +9,55 @@ */ import { describe, expect, it } from "vitest"; +import { catalogPlaneForRoute } from "../src/models-catalog.ts"; +import { INTERNAL_ROUTES } from "../src/responses-routes.ts"; import { ANTHROPIC_VERSION, authHeaderFor, gatewayPlaneFor, headerSourceOf, isGoPlaneUrl, + isZenProvider, modelIdOf, requestUrlOf, toolNamesOf, } from "../src/utils/request-shape.ts"; +describe("request-shape · isZenProvider", () => { + it("request-shape · isZenProvider: the Zen Google route is Zen, the Go routes are Go", () => { + // `opencode-google` is the Zen plane's Google route. It starts with + // `opencode-go`, so a predicate that looked for `go` anywhere read it as the + // subscription plan; the plane is `opencode-go` or one of its own + // `opencode-go-*` routes, and nothing else. + expect(isZenProvider("opencode-google")).toBe(true); + expect(isZenProvider("opencode-go-google")).toBe(false); + expect(isZenProvider("opencode-go-responses")).toBe(false); + expect(isZenProvider("opencode-go-anthropic")).toBe(false); + expect(isZenProvider("opencode-go")).toBe(false); + expect(isZenProvider("OpenCode-Go")).toBe(false); + expect(isZenProvider("opencode")).toBe(true); + expect(isZenProvider("OPENCODE")).toBe(true); + expect(isZenProvider("opencode-anthropic")).toBe(true); + expect(isZenProvider("opencode-responses")).toBe(true); + expect(isZenProvider("deepseek")).toBe(false); + expect(isZenProvider()).toBe(false); + }); + + it("agrees with catalogPlaneForRoute on every route the plugin serves", () => { + // The defect was these two predicates disagreeing; a route added to or + // renamed in responses-routes.ts must not let them drift apart again. The + // set is bounded on purpose: outside OpenCode routes the two differ by + // design (isZenProvider wants an `opencode` name; the plane defaults). + const routes = ["opencode", "opencode-go", ...INTERNAL_ROUTES]; + expect(routes.length).toBeGreaterThan(2); + for (const route of routes) { + expect(isZenProvider(route), route).toBe( + catalogPlaneForRoute(route) === "zen" + ); + } + }); +}); + describe("request-shape · the plane of a path", () => { it("reads the Go plane out of both endpoint families", () => { // The Go plane moved to the `inference` family, and three places ask this diff --git a/test/settings-card-purge.test.tsx b/test/settings-card-purge.test.tsx index 3d1073d1..500ea342 100644 --- a/test/settings-card-purge.test.tsx +++ b/test/settings-card-purge.test.tsx @@ -328,6 +328,36 @@ describe("settings-card: the states that only exist after an interaction", () => }); await settle(); expect(host.textContent).toContain(en.purgeFailed); + // In the host's warning TEXT colour: a refusal read in caption grey is + // indistinguishable from a success that removed nothing. + const line = [...host.querySelectorAll("div")].find((d) => + (d.textContent ?? "").startsWith(en.purgeFailed) + ); + expect(line?.style.color).toBe("var(--dsw-alias-state-warn-label)"); + } finally { + cleanup(); + } + }); + + it("shows an outcome that is not a refusal in caption text", async () => { + const { act, host, settle, cleanup } = await mountCard({ + purgeDeprecated: async () => ({ + kind: "purged", + result: { catalogRead: true, removed: [] }, + }), + }); + try { + const button = [...host.querySelectorAll("button")].find((b) => + (b.textContent ?? "").includes(en.purgeAction) + ); + act(() => { + button?.click(); + }); + await settle(); + const line = [...host.querySelectorAll("div")].find((d) => + (d.textContent ?? "").startsWith(en.purgeNothing) + ); + expect(line?.style.color).toBe("var(--dsw-alias-label-caption)"); } finally { cleanup(); } diff --git a/test/settings-purge.test.ts b/test/settings-purge.test.ts index f836445f..f2dc283a 100644 --- a/test/settings-purge.test.ts +++ b/test/settings-purge.test.ts @@ -50,6 +50,61 @@ describe("purgeDeprecatedModels", () => { }); }); +describe("purgeDeprecatedModels: the contract decides what a result is", () => { + it("carries the host's refusal envelope in its own words", async () => { + // The remote answers a failure as `{ ok: false, error }`. Read as a shape + // error about the envelope, the host's own reason was lost. + await expect( + purgeDeprecatedModels( + scopeWith(async () => ({ + error: new Error("no config editor on this host"), + ok: false, + })) + ) + ).resolves.toEqual({ + kind: "failed", + message: "no config editor on this host", + }); + }); + + it("names a refusal that carries no message", async () => { + await expect( + purgeDeprecatedModels(scopeWith(async () => ({ error: {}, ok: false }))) + ).resolves.toEqual({ + kind: "failed", + message: "the host refused the purge", + }); + }); + + it("refuses a value without catalogRead, which the contract requires", async () => { + const outcome = await purgeDeprecatedModels( + scopeWith(async () => ({ ok: true, value: { removed: [] } })) + ); + expect(outcome).toMatchObject({ kind: "failed" }); + }); + + it("strips a removed row down to the fields the card renders", async () => { + const outcome = await purgeDeprecatedModels( + scopeWith(async () => ({ + ok: true, + value: { + catalogRead: true, + removed: [ + { extra: 1, id: "x", provider: "opencode", site: "summarizer" }, + ], + }, + })) + ); + expect(outcome).toEqual({ + kind: "purged", + result: { + catalogRead: true, + removed: [{ id: "x", provider: "opencode", site: "summarizer" }], + }, + }); + }); +}); + describe("purgeDeprecatedModels: the paths a scope can take", () => { it("reads the namespace through ctx.get, the path that works at runtime", () => { // The `scope.remote.opencodeModelPurge` property read in the cases above is diff --git a/test/settings-store-inspect.test.tsx b/test/settings-store-inspect.test.tsx index db97239d..fc959318 100644 --- a/test/settings-store-inspect.test.tsx +++ b/test/settings-store-inspect.test.tsx @@ -269,4 +269,50 @@ describe("settings-store-inspect: the row", () => { host.remove(); } }); + + it("renders a problem note in the host's warning text colour", async () => { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + act(() => { + root.render( + createElement(StoreInspectRow, { + onInspect: () => + Promise.resolve({ kind: "unavailable" }), + t, + }) + ); + }); + const button = [...host.querySelectorAll("button")].find((b) => + (b.textContent ?? "").includes(t("storeInspect")) + ); + act(() => { + button?.click(); + }); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + + // The innermost div whose whole text is the note: with no rows, the + // report's wrapper carries the same text. + const note = [...host.querySelectorAll("div")].find( + (d) => + d.textContent === t("storeInspectUnavailable") && + d.querySelector("div") === null + ); + expect(note, "the note must be rendered").toBeDefined(); + expect(note?.style.color).toBe("var(--dsw-alias-state-warn-label)"); + } finally { + act(() => { + root.unmount(); + }); + host.remove(); + } + }); }); diff --git a/test/settings-usage.test.tsx b/test/settings-usage.test.tsx index 1a629921..9bfdc834 100644 --- a/test/settings-usage.test.tsx +++ b/test/settings-usage.test.tsx @@ -23,7 +23,12 @@ import { findAllOf } from "./test-helpers.ts"; const t = (key: string): string => zh[key as keyof typeof zh] ?? key; -/** Every string in the tree, joined — the tree is unmounted, so no DOM. */ +/** + * Every string of COPY in the tree, joined — the tree is unmounted, so no DOM. + * + * A `