Files
learnharness/tests/model-catalog.test.ts
T
amosblomqvist f9e3a4c43d feat(models): strong/cheap/vision model slots + multi-provider engine federation
Model slots wave (2026-09-20), chunk B:

- settings: new `models` section {strong, cheap, vision} slots (each
  field independent, same shape as a role override); ROLE_SLOT maps all
  nine roles to tiers (strong=prober+teacher, cheap=researcher/
  demoBuilder/extractor/auditor/labeler, vision=visualMaker+pdfReader);
  resolution chain per role: override → slot → factory pin.
- config: ModelSpec gains provider (all pins openrouter, the BYOK
  default); MODEL_PINS in stores.ts now spans all nine roles (the strong
  slot's factory rep IS the prober pin).
- engine: piModels() registers EVERY builtin provider catalog;
  piEngineForSpec(resolved spec) is the new engine factory (pi-ai
  resolves per-provider keys from env itself); piEngineFor stays as the
  config-pin fallback. chat-handler resolves the conversation chain
  (chat override → strong slot → pin) and injects engineFor into the job
  runner, closing the old gap where per-role overrides never reached
  job engines.
- /api/models: models federate ONLY keyed providers (providers surface
  with keyPresent, env-wins, fresh per call); slots (resolved tier pins)
  join rolePins; catalogDefaultsFor inherits the strong slot; the models
  settings section gets the same catalog gate as chat.
- pricing: PRICES/CONTEXT_LIMITS uniformly keyed provider:id
  (regenerated from ALL pi-ai catalogs, 1354 keys); usage stamps
  (TurnUsage, JobUsage, extraction stages) switch to provider:id —
  owner decision: no legacy compat, old stamps render unpriced.
2026-09-22 00:14:56 +03:00

230 lines
12 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { modelCatalog, validateChatSettings, validateModelsSettings, catalogDefaultsFor, catalogProviders, catalogSlots, toModelDto, rolePinsFor, catalogRolePins } from "../src/server/model-catalog.ts";
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { MODELS } from "../config.ts";
import { piEngineFor } from "../src/agent/models.ts";
import { BACKGROUND_MODEL_ROLES, type BackgroundModelRole } from "../src/core/settings.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { MODEL_PINS } from "../src/server/stores.ts";
// The model-control wave (2026-09-12): pi-ai owns the registry — these pins
// hold our projection + validation honest against the shipped catalog, and
// pin pi's per-model thinking truth so a pi-ai upgrade can't silently change
// what we send.
// The picker federates only providers with a PRESENT key (model slots
// wave, 2026-09-20): pin openrouter's env key so its full catalog
// federates in tests regardless of the developer shell's exports.
process.env.OPENROUTER_API_KEY ??= "test-key-openrouter-catalog";
describe("the model catalog projection", () => {
it("serves the keyed providers' catalogs with the picker's DTO shape", () => {
const { models, defaults, providers, slots } = modelCatalog();
// 0.85.1 ships 366 OpenRouter models — the exact count is pi's, but
// the shape and the config models' presence are ours to pin.
assert.ok(models.length > 300, `expected a full catalog, got ${models.length}`);
for (const m of models.slice(0, 25)) {
assert.equal(typeof m.id, "string");
assert.equal(typeof m.name, "string");
assert.ok(Array.isArray(m.supportedLevels));
assert.ok(typeof m.cost.input === "number" && typeof m.cost.output === "number");
assert.ok(m.contextWindow > 0);
}
const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash");
assert.ok(glm, "glm-5.3-flash in catalog");
assert.deepEqual(glm!.supportedLevels, ["low", "high", "max"]);
assert.deepEqual(glm!.input, ["text", "image"]);
// pi's current glm price (our old table said 0.15/0.5 — stale)
assert.deepEqual(glm!.cost, { input: 0.075, output: 0.25, cacheRead: 0.015, cacheWrite: 0 });
// model slots wave: the providers surface + the resolved slot pins
const openrouter = providers.find((p) => p.id === "openrouter");
assert.ok(openrouter && openrouter.keyPresent, "openrouter federated (key pinned above)");
assert.ok(providers.some((p) => !p.keyPresent), "keyless providers listed for discovery");
assert.deepEqual(Object.keys(slots), ["strong", "cheap", "vision"]);
// a keyless provider's models never reach the picker
const keyless = providers.filter((p) => !p.keyPresent).map((p) => p.id);
for (const id of keyless) {
assert.ok(!models.some((m) => m.provider === id), `keyless ${id} must not federate`);
}
});
it("defaults mirror the conversation agents' config pin (strong rep = prober pin)", () => {
const strong = { provider: "openrouter", id: MODELS.prober.model, thinkingLevel: MODELS.prober.effort as ThinkingLevel };
const d = catalogDefaultsFor(undefined, strong);
assert.deepEqual(d, { model: { provider: "openrouter", id: MODELS.prober.model }, thinkingLevel: MODELS.prober.effort });
assert.equal(d.model.id, MODELS.teacher.model, "one controller: prober and teacher share the pin");
});
it("a picked default in the record's chat section overrides the pin (catalogDefaultsFor, settings wave)", () => {
const strong = { provider: "openrouter", id: MODELS.prober.model, thinkingLevel: MODELS.prober.effort as ThinkingLevel };
const d = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" }, strong);
assert.deepEqual(d, { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" });
// partial: only the model chosen → thinking falls back to the pin
const partial = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }, strong);
assert.equal(partial.thinkingLevel, MODELS.prober.effort);
assert.equal(partial.model.id, "qwen/qwen3.8-flash");
// graphContextTokens-only record: model untouched
assert.deepEqual(catalogDefaultsFor({ graphContextTokens: 512 }, strong), {
model: { provider: "openrouter", id: MODELS.prober.model },
thinkingLevel: MODELS.prober.effort,
});
});
it("toModelDto projects the raw catalog model verbatim", () => {
const raw = piEngineFor("prober").model; // the catalog's glm entry
const dto = toModelDto(raw);
assert.equal(dto.id, raw.id);
assert.equal(dto.name, raw.name);
assert.deepEqual(dto.supportedLevels, getSupportedThinkingLevels(raw));
assert.equal(dto.cost.input, raw.cost.input);
assert.equal(dto.contextWindow, raw.contextWindow);
});
});
describe("the background roles' resolved pins (rolePins)", () => {
it("no overrides → every role pins its config MODELS spec, nested DTO shape, all six roles", () => {
const pins = rolePinsFor(resolveSettings(null, MODEL_PINS).agent.models);
assert.deepEqual(Object.keys(pins), [...BACKGROUND_MODEL_ROLES]);
for (const role of BACKGROUND_MODEL_ROLES) {
assert.deepEqual(pins[role].model, { provider: "openrouter", id: MODELS[role].model });
assert.equal(pins[role].thinkingLevel, MODELS[role].effort);
}
// the vision pin rides along like any other
assert.equal(pins.visualMaker.model.id, "qwen/qwen3.8-flash");
});
it("a model override wins for that role only; others stay pinned", () => {
const record = {
version: 1,
agent: { models: { researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } } } },
};
const pins = rolePinsFor(resolveSettings(record, MODEL_PINS).agent.models);
assert.equal(pins.researcher.model.id, "qwen/qwen3.8-flash");
assert.equal(pins.researcher.thinkingLevel, MODELS.researcher.effort, "level stays the pin unless overridden too");
assert.equal(pins.visualMaker.model.id, MODELS.visualMaker.model);
assert.equal(pins.labeler.model.id, MODELS.labeler.model);
});
it("a thinkingLevel-only override keeps the model, changes the level", () => {
const record = { version: 1, agent: { models: { extractor: { thinkingLevel: "high" as const } } } };
const pins = rolePinsFor(resolveSettings(record, MODEL_PINS).agent.models);
assert.equal(pins.extractor.model.id, MODELS.extractor.model);
assert.equal(pins.extractor.thinkingLevel, "high");
});
});
describe("the model slots surface (model slots wave, 2026-09-20)", () => {
it("catalogSlots reports the factory rep pins when no record exists", () => {
const slots = catalogSlots();
assert.deepEqual(slots.strong.model, { provider: "openrouter", id: MODELS.prober.model });
assert.deepEqual(slots.cheap.model, { provider: "openrouter", id: MODELS.researcher.model });
assert.deepEqual(slots.vision.model, { provider: "openrouter", id: MODELS.visualMaker.model });
});
it("catalogProviders reads keys env-wins, fresh per call", () => {
const before = catalogProviders().find((p) => p.id === "zai");
assert.equal(before?.keyPresent, false);
const saved = process.env.ZAI_API_KEY;
process.env.ZAI_API_KEY = "test-zai-key-1234567890";
try {
assert.equal(catalogProviders().find((p) => p.id === "zai")?.keyPresent, true);
} finally {
if (saved === undefined) delete process.env.ZAI_API_KEY;
else process.env.ZAI_API_KEY = saved;
}
});
});
describe("the models-section validation gate", () => {
it("accepts a valid slot and rejects unknown provider/model", () => {
assert.equal(
validateModelsSettings({ strong: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } } }).ok,
true,
);
assert.equal(
validateModelsSettings({ cheap: { model: { provider: "nope", id: "x" } } }).ok,
false,
);
assert.equal(
validateModelsSettings({ vision: { model: { provider: "openrouter", id: "nope/nope" } } }).ok,
false,
);
});
it("thinkingLevel resolves against the slot's model, else the tier's resolved pin", () => {
// level checked against the named model
assert.equal(
validateModelsSettings({ strong: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "max" } }).ok,
true,
);
assert.equal(
validateModelsSettings({ strong: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "minimal" } }).ok,
false,
);
// bare level: against the strong slot's resolved model (config pin)
assert.equal(validateModelsSettings({ strong: { thinkingLevel: "minimal" } }).ok, false);
});
it("accepts empty settings (the clear-slots write)", () => {
assert.deepEqual(validateModelsSettings({}), { ok: true });
});
});
describe("the settings validation gate", () => {
it("accepts a supported level and rejects an unsupported one with the list", () => {
const ok = validateChatSettings({ model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "max" });
assert.deepEqual(ok, { ok: true });
const bad = validateChatSettings({ model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "minimal" });
assert.equal(bad.ok, false);
if (!bad.ok) {
assert.match(bad.error, /does not support thinking level/);
assert.deepEqual(bad.supported, ["low", "high", "max"]);
}
});
it("resolves a bare thinking level against the config default model", () => {
assert.deepEqual(validateChatSettings({ thinkingLevel: "high" }), { ok: true });
// "minimal" is unsupported on config's glm — rejected even with no model set
assert.equal(validateChatSettings({ thinkingLevel: "minimal" }).ok, false);
});
it("rejects unknown providers and models", () => {
assert.equal(validateChatSettings({ model: { provider: "nope", id: "x/y" } }).ok, false);
assert.equal(validateChatSettings({ model: { provider: "openrouter", id: "nope/nope" } }).ok, false);
});
it("accepts empty settings (the clear-overrides write)", () => {
assert.deepEqual(validateChatSettings({}), { ok: true });
});
});
describe("pi thinking truth (pinned against pi-ai upgrades)", () => {
it("glm-5.3-flash: low|high|max; anything else clamps to the nearest", () => {
const glm = piEngineFor("prober").model;
assert.deepEqual(getSupportedThinkingLevels(glm), ["low", "high", "max"]);
assert.equal(clampThinkingLevel(glm, "minimal"), "low");
assert.equal(clampThinkingLevel(glm, "off"), "low", "GLM cannot disable thinking (map: off → null)");
assert.equal(clampThinkingLevel(glm, "xhigh"), "max");
assert.equal(clampThinkingLevel(glm, "high"), "high");
});
it("the engine factory honors settings, falls back to config, clamps", () => {
// override both fields
const over = piEngineFor("teacher", { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" });
assert.equal(over.model.id, "qwen/qwen3.8-flash");
assert.equal(over.level, "high");
// no settings → config pin
const pinned = piEngineFor("prober");
assert.equal(pinned.model.id, MODELS.prober.model);
assert.equal(pinned.level, MODELS.prober.effort);
// settings level unsupported by the settings model → clamped honestly
const clamped = piEngineFor("teacher", { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "minimal" });
assert.equal(clamped.level, "low");
// bare level resolves against the config default model
assert.equal(piEngineFor("prober", { thinkingLevel: "max" }).level, "max");
// unknown model → honest throw, never a silent fallback
assert.throws(() => piEngineFor("teacher", { model: { provider: "openrouter", id: "nope/nope" } }), /catalog has no model/);
});
});