69c7d4b8cd
PR #790 (the first bot-authored models-bump PR) shipped a broken bump for openrouter/gemini-flash: the bot pattern-matched the parallel google bump and fabricated `openrouter/google/gemini-3.5-flash`, which exists on the OpenRouter API but not on models.dev's openrouter section — the catalog OpenCode actually reads. The slug failed at runtime with `Model not found`. Two CI gaps let it through: 1. `models-live` matrix pruned every `openrouter/*` and keyed `opencode/*` alias as a "passthrough", smoke-testing only one canary per routing layer. But those aren't passthroughs — each is a distinct models.dev catalog entry that can drift independently of the direct-provider mirror. Drop the pruning; smoke every keyed alias (53 jobs, up from 25). Only `bedrock/byok` stays pruned (sentinel resolve). 2. `models-catalog` test (the integrity gate that asserts every resolve exists on models.dev) was main-only by design — to keep upstream catalog churn from blocking unrelated PRs. But it's exactly the test we want running on the bot's own catalog edits. Add `pullfrog/models-bump` head-ref to its trigger. Also tighten the bot prompt in models-bump.yml: new rule 0 requires every new `resolve` to equal `<alias-provider>/<c.modelId>` for some `c` in the alias's own `candidates[]` in models-bump-context.json — the deterministic preprocessor only emits candidates sourced from models.dev's mirror, so this gates against the cross-alias pattern-matching that broke PR #790. For `openRouterResolve` the gate is `openRouterCandidates[]` (OpenRouter API), which is necessary but not sufficient; the `models-catalog` job is the authoritative models.dev check. Verified locally: - baseline `pnpm -C action test:catalog` passes 133 tests - simulated the PR #790 hunk (sed'd `openrouter/google/gemini-3.5-flash` into action/models.ts) and the catalog test fails with the right assertion: `model "google/gemini-3.5-flash" not found under openrouter on models.dev` - `FULL=1 node action/test/matrix.ts` emits 53 aliases (was 25); every openrouter/* alias and every keyed opencode/* alias now smoked
103 lines
4.3 KiB
TypeScript
103 lines
4.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { getModelEnvVars, modelAliases, resolveCliModel, resolveDisplayAlias } from "../models.ts";
|
|
|
|
// ── pure alias-registry invariants ──────────────────────────────────────────────
|
|
//
|
|
// these tests validate our alias data structure without hitting external APIs.
|
|
// network-dependent checks (models.dev / OpenRouter catalog drift, latest-model
|
|
// snapshot) live in models-catalog.main.test.ts and run on main pushes plus
|
|
// `pullfrog/models-bump` PRs (the bot's bump branch, gated in test.yml).
|
|
|
|
// models that have no OpenRouter equivalent and require BYOK.
|
|
// add a model here ONLY when it genuinely doesn't exist on both models.dev and OpenRouter.
|
|
const BYOK_ONLY_MODELS = new Set(["openai/o3"]);
|
|
|
|
describe("openRouterResolve completeness", () => {
|
|
for (const alias of modelAliases) {
|
|
if (alias.isFree) continue;
|
|
// routing slugs (e.g. bedrock/byok) are inherently BYOK — there's no
|
|
// single model to map to OpenRouter because the actual model ID is read
|
|
// from a per-run env var.
|
|
if (alias.routing) continue;
|
|
if (BYOK_ONLY_MODELS.has(alias.slug)) continue;
|
|
it(`${alias.slug} has openRouterResolve`, () => {
|
|
expect(
|
|
alias.openRouterResolve,
|
|
`non-free model "${alias.slug}" is missing openRouterResolve — add it or add to BYOK_ONLY_MODELS`
|
|
).toBeDefined();
|
|
});
|
|
}
|
|
|
|
for (const alias of modelAliases) {
|
|
if (!alias.isFree) continue;
|
|
it(`${alias.slug} (free) does not need openRouterResolve`, () => {
|
|
expect(alias.openRouterResolve).toBeUndefined();
|
|
});
|
|
}
|
|
|
|
for (const alias of modelAliases) {
|
|
if (!alias.routing) continue;
|
|
it(`${alias.slug} (routing slug) has no openRouterResolve`, () => {
|
|
expect(alias.openRouterResolve).toBeUndefined();
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("fallback chain resolution", () => {
|
|
for (const alias of modelAliases.filter((a) => a.fallback)) {
|
|
it(`${alias.slug} fallback chain resolves to a non-deprecated model`, () => {
|
|
const resolved = resolveCliModel(alias.slug);
|
|
expect(
|
|
resolved,
|
|
`fallback chain for "${alias.slug}" does not resolve to a non-deprecated model`
|
|
).toBeDefined();
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── 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);
|
|
});
|
|
}
|
|
});
|