Files
shockbot/test/models.test.ts
T
Colin McDonnell 69c7d4b8cd models-bump: harden CI and bot prompt against catalog hallucinations
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
2026-05-20 02:18:38 +00:00

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);
});
}
});