diff --git a/models.test.ts b/models.test.ts index cc30298..012fd0b 100644 --- a/models.test.ts +++ b/models.test.ts @@ -55,13 +55,13 @@ describe("getModelEnvVars", () => { it("returns empty env vars for free opencode models", () => { expect(getModelEnvVars("opencode/big-pickle")).toEqual([]); - expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual([]); expect(getModelEnvVars("opencode/mimo-v2-pro-free")).toEqual([]); expect(getModelEnvVars("opencode/minimax-m2.5-free")).toEqual([]); }); it("still requires OPENCODE_API_KEY for non-free opencode models", () => { expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]); + expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual(["OPENCODE_API_KEY"]); }); }); diff --git a/models.ts b/models.ts index 4c77c49..0a549ea 100644 --- a/models.ts +++ b/models.ts @@ -271,8 +271,7 @@ export const providers = { "gpt-5-nano": { displayName: "GPT Nano", resolve: "opencode/gpt-5-nano", - envVars: [], - isFree: true, + openRouterResolve: "openrouter/openai/gpt-5-nano", }, "mimo-v2-pro-free": { displayName: "MiMo V2 Pro", diff --git a/test/list-aliases.ts b/test/list-aliases.ts index fbfdaee..7f6210c 100644 --- a/test/list-aliases.ts +++ b/test/list-aliases.ts @@ -50,8 +50,9 @@ const FLAGSHIPS = [ function isPrunablePassthrough(alias: (typeof modelAliases)[number]): boolean { if (ROUTING_CANARIES.has(alias.slug)) return false; if (alias.provider === "openrouter") return true; - // opencode FREE models (big-pickle, mimo, minimax, gpt-5-nano) are unique - // to opencode and used in prod — keep them. only prune the keyed mirrors. + // opencode FREE models (big-pickle, mimo-v2-pro-free, minimax-m2.5-free) + // are unique to opencode and used in prod — keep them. only prune the keyed + // mirrors. return alias.provider === "opencode" && !alias.isFree; } diff --git a/test/models-catalog.main.test.ts b/test/models-catalog.main.test.ts index 5fb8e65..bbae95f 100644 --- a/test/models-catalog.main.test.ts +++ b/test/models-catalog.main.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { modelAliases } from "../models.ts"; +import { modelAliases, resolveDisplayAlias } from "../models.ts"; // ── catalog drift tests — main-only ───────────────────────────────────────────── // @@ -21,6 +21,7 @@ type ModelsDevModel = { name: string; status?: string; release_date?: string; + cost?: { input?: number; output?: number }; }; type ModelsDevProvider = { @@ -112,3 +113,70 @@ describe("openRouterResolve OpenRouter API validity", async () => { }); } }); + +// ── OpenCode Zen served-list + free-cost checks ──────────────────────────────── +// +// these enforce the two dynamic conditions for "this opencode alias works for a +// user without OPENCODE_API_KEY" — the gap that let issue #691 ship: +// 1. the alias's terminal-fallback resolve appears in Zen's /v1/models (Zen +// actually serves it). caught nothing in #691 because mimo had a fallback +// to big-pickle which IS served, but would catch any future alias that +// points at a Zen-removed model without a fallback. +// 2. for isFree aliases, the terminal-fallback's models.dev `cost.input` is +// zero. caught the gpt-5-nano regression: $0.05/M input on models.dev, +// marked isFree in our catalog. +// +// we check the terminal-fallback (via resolveDisplayAlias) because deprecated +// aliases legitimately point at dead resolve targets — the terminal is what +// actually runs at the agent CLI. + +type ZenModel = { id: string }; +type ZenModelsResponse = { data: ZenModel[] }; + +const zenApi = fetch("https://opencode.ai/zen/v1/models").then( + (r) => r.json() as Promise +); + +describe("opencode Zen served list", async () => { + const zenData = await zenApi; + const zenIds = new Set(zenData.data.map((m) => m.id)); + const seen = new Set(); + + for (const alias of modelAliases) { + const terminal = resolveDisplayAlias(alias.slug); + if (!terminal) continue; + const parsed = parseResolve(terminal.resolve); + if (parsed.provider !== "opencode") continue; + if (seen.has(terminal.resolve)) continue; + seen.add(terminal.resolve); + + it(`${alias.slug} terminal resolve ${terminal.resolve} is served by Zen`, () => { + expect( + zenIds.has(parsed.modelId), + `terminal resolve "${terminal.resolve}" for alias "${alias.slug}" is not in https://opencode.ai/zen/v1/models — Zen no longer serves it. either point a fallback at a Zen-served alias or remove the entry.` + ).toBe(true); + }); + } +}); + +describe("isFree models.dev cost", async () => { + const data = await api; + const seen = new Set(); + + for (const alias of modelAliases.filter((a) => a.isFree)) { + const terminal = resolveDisplayAlias(alias.slug); + if (!terminal) continue; + const parsed = parseResolve(terminal.resolve); + if (seen.has(terminal.resolve)) continue; + seen.add(terminal.resolve); + + it(`${alias.slug} terminal resolve ${terminal.resolve} has cost.input === 0`, () => { + const model = data[parsed.provider]?.models[parsed.modelId]; + expect(model, `terminal resolve "${terminal.resolve}" missing on models.dev`).toBeDefined(); + expect( + model?.cost?.input, + `isFree alias "${alias.slug}" walks to "${terminal.resolve}" which reports cost.input=${model?.cost?.input} on models.dev — either repoint the fallback or drop \`isFree\`` + ).toBe(0); + }); + } +}); diff --git a/test/models.test.ts b/test/models.test.ts index ac0cff1..946682c 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { modelAliases, resolveCliModel } from "../models.ts"; +import { getModelEnvVars, modelAliases, resolveCliModel, resolveDisplayAlias } from "../models.ts"; // ── pure alias-registry invariants ────────────────────────────────────────────── // @@ -42,3 +42,49 @@ describe("fallback chain resolution", () => { }); } }); + +// ── isFree invariants — sanity-check the catalog data shape ───────────────────── +// +// these catch the latent regressions that produced issue #691: +// - opencode/gpt-5-nano was marked `isFree` despite costing $0.05/M +// (no static check existed; demoted to paid in the same PR adding these tests) +// - opencode/mimo-v2-pro-free was free + fallback to big-pickle (correct shape), +// but nothing enforced that the terminal of an isFree fallback chain is itself +// free. if someone repointed big-pickle's fallback at a paid model, all of mimo +// and big-pickle's users would silently start hitting a paid endpoint. +// +// the cost.input check itself is network-dependent (lives in +// models-catalog.main.test.ts); these are the static sibling that runs on every PR. +describe("isFree invariants", () => { + for (const alias of modelAliases.filter((a) => a.isFree)) { + it(`${alias.slug} lives under the opencode provider`, () => { + expect( + alias.provider, + `isFree alias "${alias.slug}" must be under "opencode" (Zen's keyless gate is opencode-only)` + ).toBe("opencode"); + }); + + it(`${alias.slug} has empty envVars`, () => { + expect( + getModelEnvVars(alias.slug), + `isFree alias "${alias.slug}" must declare \`envVars: []\` so validateAgentApiKey doesn't demand OPENCODE_API_KEY` + ).toEqual([]); + }); + + it(`${alias.slug} has no openRouterResolve`, () => { + expect( + alias.openRouterResolve, + `isFree alias "${alias.slug}" must omit \`openRouterResolve\` — free Zen models don't exist on OpenRouter` + ).toBeUndefined(); + }); + + it(`${alias.slug} fallback chain terminates at an isFree alias`, () => { + const terminal = resolveDisplayAlias(alias.slug); + expect(terminal, `fallback chain for "${alias.slug}" is broken`).toBeDefined(); + expect( + terminal?.isFree, + `isFree alias "${alias.slug}" walks to "${terminal?.slug}" which is NOT isFree — users would silently start paying` + ).toBe(true); + }); + } +}); diff --git a/utils/apiKeys.test.ts b/utils/apiKeys.test.ts index 397ee79..22cc11c 100644 --- a/utils/apiKeys.test.ts +++ b/utils/apiKeys.test.ts @@ -27,11 +27,7 @@ describe("validateAgentApiKey", () => { }); it("passes for other free opencode models", () => { - for (const slug of [ - "opencode/gpt-5-nano", - "opencode/mimo-v2-pro-free", - "opencode/minimax-m2.5-free", - ]) { + for (const slug of ["opencode/mimo-v2-pro-free", "opencode/minimax-m2.5-free"]) { expect(() => validateAgentApiKey({ ...base, model: slug })).not.toThrow(); } }); @@ -59,6 +55,12 @@ describe("validateAgentApiKey", () => { "no API key found" ); }); + + it("throws for opencode/gpt-5-nano without OPENCODE_API_KEY (paid Zen alias)", () => { + expect(() => validateAgentApiKey({ ...base, model: "opencode/gpt-5-nano" })).toThrow( + "no API key found" + ); + }); }); describe("no model (auto-select)", () => {