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. 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");