Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tag> --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.
18 changes: 12 additions & 6 deletions src/key-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
}
}
Expand Down
13 changes: 10 additions & 3 deletions src/models-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions src/models-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
};

/**
Expand Down
71 changes: 63 additions & 8 deletions src/utils/request-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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 "";
Expand Down
44 changes: 44 additions & 0 deletions test/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions test/request-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions test/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading