From 6016cd76a2d24525938196e4e36d1e4713663c26 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 23 Oct 2026 01:53:21 +0800 Subject: [PATCH 1/2] docs: merge with squash, so the changelog has one line per change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-please resolves a MERGE COMMIT to the pull request's title, so under a merge-commit history every pull request appears in `CHANGELOG.md` once for its title and once for each commit inside it. The 1.4.0 notes listed nine entries for four changes, and a reader cannot tell a real second change from a repeated one. A squashed commit carries the pull request's title and nothing else, so each change is exactly one line — and the title is then the release signal, which is why it must be a Conventional Commit. --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5ef55039..c936af63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,3 +134,11 @@ node --experimental-strip-types scripts/check.ts # bundle gate Releases are cut by release-please from Conventional Commit subjects, and published to npm by `.github/workflows/release.yml` through OIDC trusted publishing (no stored token). **`CHANGELOG.md` is owned by release-please — never hand-edit it**; clarity comes from commit subjects and the `changelog-sections` mapping in the release-please workflow. **Let release-please drive the release.** Its version anchor is the latest GitHub **Release**, not the latest tag, so a manual cut (`npm version` + `git tag` + push) creates a tag it cannot see. It then keeps computing from the last release it knows: it re-proposes a version that is already published, that pull request _regresses_ `package.json`, and its next run fails with `already_exists` when it tries to create the release a second time. If a manual cut is ever necessary, create the GitHub Release as part of it (`gh release create --generate-notes`). + +## Merging: squash + +Pull requests are merged with **squash**, so one pull request is one commit on `main`. + +The reason is the changelog, and it is mechanical rather than stylistic: release-please resolves a **merge commit** to the _pull request's title_, so under a merge-commit history every pull request appears in `CHANGELOG.md` once for its title and once for each commit inside it. The 1.4.0 notes listed nine entries for four changes, and a reader cannot tell a real second change from a repeated one. A squashed commit carries the pull request's title and nothing else, so each change is exactly one line. + +**The pull request's title is therefore the release signal**, and it must be a Conventional Commit — `feat:`, `fix:`, `chore:` — because the version bump and the changelog section are read from it. The commits inside the branch keep their own subjects, which is where the detail lives for the review; they do not reach the changelog. From 6e5bcf84e431b321a5f3641482c70956ec78a709 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 23 Oct 2026 01:53:21 +0800 Subject: [PATCH 2/2] fix(discovery): read the plane from the path, in both endpoint families MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The Go plane's model list was answered with the Zen catalog.** Three places ask which plane a URL belongs to, and they had three answers, because the newer `inference` family was added to each of them separately and each addition was partial: | path | the old substring checks said | is | | :-- | :-- | :-- | | `/inference/go/openai/v1` | zen (two of the three knew only `/zen/go`) | go | | `/inference/google/v1beta` | **go** (`/inference/go` is a PREFIX of it) | **zen** | | `/inference/openai/v1` | unknown (`/inference/v1` is not inside it) | zen | The first row is the reported symptom. The Go plane's base is the `inference` family now, and the adapter lists its models at `${base}/models` — a path the gateway does not serve, so the answer comes from this plugin's enrichment. That enrichment asks which plane the listing is on, read by substring, and it read the modern Go path as the Zen plane. The Go plane was therefore answered with the Zen catalog: every Zen model, and none of the Go plane's own — the Gemini contributor and the Muse contributor, both of which the deployment declares. Measured against the real gateway, through the patched fetch: | listing | before | after | | :-- | :-- | :-- | | `…/zen/go/v1/models` | 36 Go models | 36 Go models | | `…/inference/go/openai/v1/models` | **82 Zen models** | **28 Go models** | The second row is a credential filed under the wrong account: the Zen google family's captured key was filed as a Go key, and used as one. The third is a credential filed under no account at all, which is the "my key is invalid" report on a key that was fine — the same report this plugin has already been asked about once. **One predicate answers it now**, in `src/utils/request-shape.ts`, and the three call sites use it: `isGoModelsListingUrl`, `resolveDiscoveryProvider` and `tierForRequest`. The segment boundary is load-bearing — `/inference/go` must END there, or the Zen google family is read as the Go plane. A relative path answers too (`/zen/go/v1/models`), because a caller with no origin passes a path and the plane is a property of the path; a string that is neither a URL nor a path is still not guessed at. **The Go plane's other traffic is unaffected**, and measured: the gateway serves `/inference/go/openai/v1/chat/completions` (200, a real completion), `/inference/go/google/v1beta/…:generateContent` (200, a real completion) and `/zen/go/v1/usage` (200, the quota panel's endpoint, which stays on the older family because there is no `/inference/…/usage` counterpart). The three planted violations of the old behaviour each fail the new tests. --- src/key-capture.ts | 18 ++++++---- src/models-catalog.ts | 13 +++++-- src/models-discovery.ts | 10 +++--- src/utils/request-shape.ts | 71 +++++++++++++++++++++++++++++++++----- test/catalog.test.ts | 44 +++++++++++++++++++++++ test/request-shape.test.ts | 50 +++++++++++++++++++++++++++ test/usage.test.ts | 34 ++++++++++++++++++ 7 files changed, 219 insertions(+), 21 deletions(-) diff --git a/src/key-capture.ts b/src/key-capture.ts index 6db7a30c..c6edb24b 100644 --- a/src/key-capture.ts +++ b/src/key-capture.ts @@ -16,6 +16,7 @@ */ import { isRecord } from "./utils/guards.ts"; +import { gatewayPlaneFor } from "./utils/request-shape.ts"; /** Account tiers a captured credential can belong to. */ export type KeyTier = "go" | "zen" | "unknown"; @@ -87,14 +88,19 @@ export const tierForRequest = ( // `inference` family as `unknown`, which made a captured, working key // invisible to the resolver and sent the adapter's `Bearer unused` // placeholder instead — a 401 the user reads as an invalid API key. - if ( - url.includes("/zen/go") || - url.includes("/inference/go") || - url.includes("/go/v1") - ) { + // + // **The path is read as a path**, by the one predicate that answers for + // every caller. The substring checks this replaces each got a piece of the + // newer family wrong: `/inference/go` is a PREFIX of `/inference/google`, + // so the ZEN google family was filed as a Go key, and `/inference/v1` is + // not in `/inference/openai/v1`, so the ZEN openai family was filed under + // no account at all. A key filed under the wrong account is worse than one + // filed under none: it is used, and it is the wrong one. + const plane = gatewayPlaneFor(url); + if (plane === "opencode-go") { return "go"; } - if (url.includes("/zen/v1") || url.includes("/inference/v1")) { + if (plane === "opencode") { return "zen"; } } diff --git a/src/models-catalog.ts b/src/models-catalog.ts index 6dfbec95..dae9c5ea 100644 --- a/src/models-catalog.ts +++ b/src/models-catalog.ts @@ -22,6 +22,7 @@ import { import { DEFAULT_GATEWAY_URLS } from "./config.ts"; import type { ModelCostRate } from "./session-cost.ts"; import { isRecord } from "./utils/guards.ts"; +import { isGoPlaneUrl } from "./utils/request-shape.ts"; // The catalog data is part of this module's public surface (and of the // package's, via `index.ts`), so re-export it rather than making callers @@ -658,13 +659,19 @@ export const isModelsListingUrl = ( * The plane is a property of the PATH, never of the host: a custom gateway's Go * route is still `/go/v1`, so this reads the same way for a relay as for * opencode.ai — which is exactly why the marker above had to be lifted out. + * + * **Both endpoint families, and the newer one is the one that matters here.** + * The Go plane's base is the `inference` family (`…/inference/go/openai/v1`), + * and that path carries no `/models` of its own — the listing lives on the + * older `zen/go` route, so the answer the gateway gives is a 404 and this + * enrichment is what fills it. Reading the plane by substring instead put the + * modern Go path in the ZEN bucket, and the Go plane was then answered with the + * ZEN catalog: every Zen model, and none of the Go plane's own. */ export const isGoModelsListingUrl = ( url: string, gatewayUrls?: readonly string[] -): boolean => - isModelsListingUrl(url, gatewayUrls) && - (url.includes("/zen/go") || url.includes("/go/v1")); +): boolean => isModelsListingUrl(url, gatewayUrls) && isGoPlaneUrl(url); /** * Merge and enrich a gateway `/models` response with the complete OpenCode Go or Zen catalog. diff --git a/src/models-discovery.ts b/src/models-discovery.ts index 1fdb4015..fa0ef70a 100644 --- a/src/models-discovery.ts +++ b/src/models-discovery.ts @@ -43,6 +43,7 @@ import { UNSERVED_PLANE_SDKS, } from "./responses-routes.ts"; import { isRecord } from "./utils/guards.ts"; +import { isGoPlaneUrl } from "./utils/request-shape.ts"; export interface DiscoveryCandidate { contextWindow?: number; @@ -117,10 +118,11 @@ export const resolveDiscoveryProvider = ( if (!isGateway || baseURL === undefined) { return undefined; } - return normalizedBaseURL.includes("/zen/go") || - normalizedBaseURL.includes("/go/v1") - ? "go" - : "zen"; + // The plane is a property of the path, and the path is read as a path: the + // Go plane's base is the `inference` family (`…/inference/go/openai/v1`), and + // a substring check that does not know that path attributes a Go discovery + // request to the ZEN catalog. + return isGoPlaneUrl(baseURL) ? "go" : "zen"; }; /** diff --git a/src/utils/request-shape.ts b/src/utils/request-shape.ts index 629fdd69..4e7c24be 100644 --- a/src/utils/request-shape.ts +++ b/src/utils/request-shape.ts @@ -17,6 +17,65 @@ import { isRecord } from "./guards.ts"; +/** + * The PATH of a gateway URL, whether it is absolute or a bare path. + * + * A relative path is still a path — `"/zen/go/v1/models"` is what a caller with + * no origin passes, and the plane is a property of the path. Anything else that + * does not parse answers `""`, because guessing from a malformed string is how a + * rotation lands on the wrong credential. + */ +const pathOf = (url: string): string => { + try { + return new URL(url).pathname; + } catch { + return url.startsWith("/") ? (url.split("?")[0] ?? "") : ""; + } +}; + +/** + * The Go plane's paths, in both endpoint families. + * + * The vendor serves the Go plane under `/zen/go` on the older family and under + * `/inference/go` on the newer one, and a relay may use its own `/go/v1`. + * + * **The segment boundary is load-bearing.** `/inference/go` is a PREFIX of + * `/inference/google`, which is the ZEN google family — a plain `includes` + * reads it as the Go plane, and a captured key then belongs to the wrong + * account. So the segment must END there: `/inference/go` followed by a + * separator or the end of the path, never by more letters. + */ +const GO_PLANE_PATH = + /\/zen\/go(?:\/|$)|\/inference\/go(?:\/|$)|\/go\/v1(?:\/|$)/u; + +/** The Zen plane's paths, in both endpoint families. */ +const ZEN_PLANE_PATH = /\/zen(?:\/|$)|\/inference(?:\/|$)/u; + +/** + * Whether a URL is on the GO plane. + * + * **One answer for every caller.** Three places ask this question — the model + * listing's plane, a discovery request's plane, and the tier a captured key + * belongs to — and they had three answers, because the newer `inference` + * family was added to each of them separately and each addition was partial: + * + * | path | the old substring checks said | + * | :-- | :-- | + * | `/inference/go/openai/v1` | zen (the Go path was unknown to two of them) | + * | `/inference/google/v1beta` | **go** (`/inference/go` is a prefix of it) | + * | `/inference/openai/v1` | unknown (`/inference/v1` is not in it) | + * + * The middle row is a credential filed under the wrong account; the third is a + * credential filed under no account at all, which is the "my key is invalid" + * report. Both are the same mistake: a plane is a property of the PATH, and + * the path has to be read as a path. + * + * @param url - a request URL or a bare path. + * @returns whether this URL belongs to the Go plane. + */ +export const isGoPlaneUrl = (url: string): boolean => + GO_PLANE_PATH.test(pathOf(url)); + /** * Which gateway plane a URL belongs to. * @@ -30,20 +89,16 @@ import { isRecord } from "./guards.ts"; * @returns `opencode-go`, `opencode`, or `""` when the URL is not a gateway URL. */ export const gatewayPlaneFor = (url: string): string => { - let path: string; - try { - path = new URL(url).pathname; - } catch { - // An unparseable URL is not a gateway URL, and guessing a plane from a - // malformed string is how a rotation lands on the wrong credential. + const path = pathOf(url); + if (path === "") { return ""; } // Both endpoint families, because a credential's format decides which one a // request belongs on and the plane is the same either way. - if (/\/zen\/go\/|\/inference\/go\//u.test(path)) { + if (GO_PLANE_PATH.test(path)) { return "opencode-go"; } - if (/\/zen\/|\/inference\//u.test(path)) { + if (ZEN_PLANE_PATH.test(path)) { return "opencode"; } return ""; diff --git a/test/catalog.test.ts b/test/catalog.test.ts index ece37967..a95579b3 100644 --- a/test/catalog.test.ts +++ b/test/catalog.test.ts @@ -137,6 +137,21 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(isGoModelsListingUrl("https://opencode.ai/zen/v1/models")).toBe( false ); + // **The modern family's Go path.** The Go plane's base is the `inference` + // family now, and that path carries no `/models` of its own — the gateway + // answers 404 there and this enrichment is what fills it. Reading the plane + // by substring put it in the ZEN bucket, and the Go plane was then answered + // with the Zen catalog: every Zen model, and none of the Go plane's own. + expect( + isGoModelsListingUrl("https://opencode.ai/inference/go/openai/v1/models") + ).toBe(true); + expect( + isGoModelsListingUrl("https://opencode.ai/inference/openai/v1/models") + ).toBe(false); + // And the Zen google family is not the Go plane, though it starts the same. + expect( + isGoModelsListingUrl("https://opencode.ai/inference/google/v1beta/models") + ).toBe(false); expect( isModelsListingUrl("https://opencode.ai/zen/v1/chat/completions") ).toBe(false); @@ -338,6 +353,35 @@ describe("OpenCode Model Catalog & Enrichment", () => { expect(zenJson.data.some((m) => m.id === "beam-free")).toBe(true); }); + it("answers the modern Go listing with the Go catalog, not the Zen one", async () => { + // **This is the bug the Go plane's endpoint change exposed.** The Go plane's + // base is the `inference` family, and the adapter lists its models at + // `${base}/models` — a path the gateway does not serve, so the answer comes + // from here. The plane of that URL used to be read by substring, which knew + // neither `/inference/go` nor the boundary that separates it from + // `/inference/google`: the Go plane was answered with the ZEN catalog, and + // its own models — the Gemini contributor, the Muse contributor — were not + // in the answer at all. + const goResponse = await enrichModelsResponse( + "https://opencode.ai/inference/go/openai/v1/models", + // What the gateway actually answers there: a 404's body. + Response.json({ error: { message: "not found" } }, { status: 404 }) + ); + const goJson = (await goResponse.json()) as { + data: Array<{ id: string }>; + }; + // The GO catalog's own rows are in it… + expect(goJson.data.some((m) => m.id === "gemini-4-argon-contributor")).toBe( + true + ); + expect(goJson.data.some((m) => m.id === "muse-spark-1.3-contributor")).toBe( + true + ); + // …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); + }); + it("keeps the Muse 1.2 rows the zen plane actually serves", async () => { const goResponse = await enrichModelsResponse( "https://opencode.ai/zen/go/v1/models", diff --git a/test/request-shape.test.ts b/test/request-shape.test.ts index 4f42cbe8..e2a14ed6 100644 --- a/test/request-shape.test.ts +++ b/test/request-shape.test.ts @@ -14,11 +14,61 @@ import { authHeaderFor, gatewayPlaneFor, headerSourceOf, + isGoPlaneUrl, modelIdOf, requestUrlOf, toolNamesOf, } from "../src/utils/request-shape.ts"; +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 + // question — the model listing's plane, a discovery request's plane, and + // the tier a captured key belongs to. Each had its own answer, and each + // answer was partial, so the question is asked once here. + expect(isGoPlaneUrl("https://opencode.ai/zen/go/v1/models")).toBe(true); + expect( + isGoPlaneUrl("https://opencode.ai/inference/go/openai/v1/models") + ).toBe(true); + expect( + isGoPlaneUrl("https://opencode.ai/inference/go/google/v1beta/models") + ).toBe(true); + // A relay's own Go route, under someone else's host. + expect( + isGoPlaneUrl("https://relay.example.com/go/v1/chat/completions") + ).toBe(true); + }); + + it("does not read the Zen google family as the Go plane", () => { + // `/inference/go` is a PREFIX of `/inference/google`. A substring check + // therefore files a Zen google request under the Go account, and the key + // captured from it is then used for Go requests — the wrong credential, + // which is worse than no credential: it fails somewhere else. + expect( + isGoPlaneUrl("https://opencode.ai/inference/google/v1beta/models") + ).toBe(false); + expect(isGoPlaneUrl("https://opencode.ai/inference/google/v1beta")).toBe( + false + ); + expect( + gatewayPlaneFor("https://opencode.ai/inference/google/v1beta/models") + ).toBe("opencode"); + expect(isGoPlaneUrl("https://opencode.ai/inference/openai/v1/chat")).toBe( + false + ); + }); + + it("answers for a bare path as well as for a URL", () => { + // A caller with no origin passes the path, and the plane is a property of + // the path. + expect(isGoPlaneUrl("/zen/go/v1/models")).toBe(true); + expect(isGoPlaneUrl("/inference/go/openai/v1/models")).toBe(true); + expect(isGoPlaneUrl("/inference/openai/v1/models")).toBe(false); + // And a string that is neither a URL nor a path is not guessed at. + expect(isGoPlaneUrl("not a url")).toBe(false); + }); +}); + describe("request-shape · gatewayPlaneFor", () => { it("attributes each endpoint family to its plane", () => { // Both families, because a credential's format decides which one a request diff --git a/test/usage.test.ts b/test/usage.test.ts index d2acb021..fee756f2 100644 --- a/test/usage.test.ts +++ b/test/usage.test.ts @@ -488,6 +488,40 @@ describe("OpenCode Go Usage", () => { ); }); + it("files a key captured on the newer family under the right account", () => { + // The newer family has three paths of its own, and the substring checks + // this replaces each got one of them wrong: `/inference/go` is a PREFIX of + // `/inference/google`, so the ZEN google family was filed as a Go key — + // a credential used on the wrong account, which fails further away than a + // missing one — and `/inference/v1` is not inside `/inference/openai/v1`, + // so the ZEN openai family was filed under no account at all, which is + // the "my key is invalid" report on a key that was fine. + expect( + tierForRequest( + "https://opencode.ai/inference/openai/v1/chat/completions" + ) + ).toBe("zen"); + expect( + tierForRequest("https://opencode.ai/inference/anthropic/v1/messages") + ).toBe("zen"); + expect( + tierForRequest( + "https://opencode.ai/inference/google/v1beta/models/x:generateContent" + ) + ).toBe("zen"); + // And the Go side of the newer family is still the Go side. + expect( + tierForRequest( + "https://opencode.ai/inference/go/openai/v1/chat/completions" + ) + ).toBe("go"); + expect( + tierForRequest( + "https://opencode.ai/inference/go/google/v1beta/models/x:generateContent" + ) + ).toBe("go"); + }); + it("uses the provider id only when nothing else identifies the tier", () => { expect(tierForRequest(undefined, "opencode-go")).toBe("go"); expect(tierForRequest(undefined, "opencode")).toBe("zen");