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.
This commit is contained in:
amosblomqvist
2026-09-22 00:14:56 +03:00
parent f1373d5de5
commit f9e3a4c43d
26 changed files with 3529 additions and 912 deletions
+11 -1
View File
@@ -21,13 +21,14 @@
* lands here. * lands here.
*/ */
import type { AgentSettings, ChatSettings } from "@/src/core"; import type { AgentSettings, ChatSettings, ModelsSettings } from "@/src/core";
import { import {
SETTINGS_SECTIONS, SETTINGS_SECTIONS,
allSegmentPaths, allSegmentPaths,
promptOverrides, promptOverrides,
readSettingsRecord, readSettingsRecord,
validateChatSettings, validateChatSettings,
validateModelsSettings,
writeSettingsRecord, writeSettingsRecord,
type SettingsBodyResult, type SettingsBodyResult,
} from "@/src/server"; } from "@/src/server";
@@ -60,6 +61,15 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 }); return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
} }
} }
// Model slots wave (2026-09-20): the models section gets the same
// catalog gate the chat section gets — a slot must name a real
// provider+model and a thinking level it supports.
if (section === "models") {
const verdict = validateModelsSettings(parsed.settings as ModelsSettings);
if (!verdict.ok) {
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
}
}
if (section === "agent") { if (section === "agent") {
const agent = parsed.settings as AgentSettings; const agent = parsed.settings as AgentSettings;
const segments = new Set(allSegmentPaths()); const segments = new Set(allSegmentPaths());
+15 -10
View File
@@ -46,7 +46,12 @@ export const PATHS = {
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export interface ModelSpec { export interface ModelSpec {
/** OpenRouter model id (e.g. "z-ai/glm-5.3-flash") — must exist in pi-ai's catalog. */ /** The provider that owns the model (model slots wave, 2026-09-20):
* pi-ai provider id. Native ids are plain strings ("glm-5.3-flash" on
* zai); OpenRouter ids are vendor slugs ("z-ai/glm-5.3-flash"). The
* factory pins stay OpenRouter (the BYOK default provider). */
provider: string;
/** The model id on that provider — must exist in pi-ai's catalog. */
model: string; model: string;
/** Thinking budget — what the model may spend on reasoning tokens. */ /** Thinking budget — what the model may spend on reasoning tokens. */
effort: ThinkingLevel; effort: ThinkingLevel;
@@ -54,28 +59,28 @@ export interface ModelSpec {
export const MODELS = { export const MODELS = {
/** The prober (agent A) — edge-bracketing questions; keep it lively and adaptive. */ /** The prober (agent A) — edge-bracketing questions; keep it lively and adaptive. */
prober: { model: "z-ai/glm-5.3-flash", effort: "low" }, prober: { provider: "openrouter", model: "z-ai/glm-5.3-flash", effort: "low" },
/** The teacher (agent B) — planning + streaming, lesson-facing: smart, but keep it lively. */ /** The teacher (agent B) — planning + streaming, lesson-facing: smart, but keep it lively. */
teacher: { model: "z-ai/glm-5.3-flash", effort: "low" }, teacher: { provider: "openrouter", model: "z-ai/glm-5.3-flash", effort: "low" },
/** Background sub-agents: fast and cheap beats deep thought. */ /** Background sub-agents: fast and cheap beats deep thought. */
researcher: { model: "z-ai/glm-5.3-flash", effort: "low" }, researcher: { provider: "openrouter", model: "z-ai/glm-5.3-flash", effort: "low" },
demoBuilder: { model: "z-ai/glm-5.3-flash", effort: "low" }, demoBuilder: { provider: "openrouter", model: "z-ai/glm-5.3-flash", effort: "low" },
/** Must be vision-capable: it looks at rendered SVGs. */ /** Must be vision-capable: it looks at rendered SVGs. */
visualMaker: { model: "qwen/qwen3.8-flash", effort: "low" }, visualMaker: { provider: "openrouter", model: "qwen/qwen3.8-flash", effort: "low" },
/** SEAM (master-approved 2026-09, revised by the user 2026-09-19): the /** SEAM (master-approved 2026-09, revised by the user 2026-09-19): the
* pdf-reader sub-agent. Must be vision-capable — it looks at rendered * pdf-reader sub-agent. Must be vision-capable — it looks at rendered
* PDF pages; its briefs are text. The user overrode the initial * PDF pages; its briefs are text. The user overrode the initial
* glm-5.3-flash pick (catalog says it accepts images, but qwen is * glm-5.3-flash pick (catalog says it accepts images, but qwen is
* proven for exactly this look-at-renders job — visualMaker's daily * proven for exactly this look-at-renders job — visualMaker's daily
* workload). */ * workload). */
pdfReader: { model: "qwen/qwen3.8-flash", effort: "low" }, pdfReader: { provider: "openrouter", model: "qwen/qwen3.8-flash", effort: "low" },
/** Extraction is judgment over given evidence — light thinking suffices. */ /** Extraction is judgment over given evidence — light thinking suffices. */
extractor: { model: "z-ai/glm-5.3-flash", effort: "low" }, extractor: { provider: "openrouter", model: "z-ai/glm-5.3-flash", effort: "low" },
/** The auditor verifies evidence exists; keep it cheap. */ /** The auditor verifies evidence exists; keep it cheap. */
auditor: { model: "z-ai/glm-5.3-flash", effort: "low" }, auditor: { provider: "openrouter", model: "z-ai/glm-5.3-flash", effort: "low" },
/** Names detected communities — one singular call, given member names /** Names detected communities — one singular call, given member names
* + statements. Purely a labeling flourish; keep it cheap. */ * + statements. Purely a labeling flourish; keep it cheap. */
labeler: { model: "z-ai/glm-5.3-flash", effort: "low" }, labeler: { provider: "openrouter", model: "z-ai/glm-5.3-flash", effort: "low" },
} satisfies Record<string, ModelSpec>; } satisfies Record<string, ModelSpec>;
export type ModelRole = keyof typeof MODELS; export type ModelRole = keyof typeof MODELS;
+49 -30
View File
@@ -1,23 +1,26 @@
/** /**
* Regenerate src/core/prices.ts tables from pi-ai's shipped OpenRouter * Regenerate src/core/prices.ts tables from pi-ai's shipped per-provider
* catalog (providers/data/openrouter.json). * catalogs (providers/data/*.json).
* *
* Usage: npx tsx scripts/sync-pricing.ts * Usage: npx tsx scripts/sync-pricing.ts
* *
* The catalog is pi-ai's curated, versioned snapshot of OpenRouter's model * The catalogs are pi-ai's curated, versioned snapshots — the SAME source
* list — the SAME source piEngineFor resolves engines from and * piEngineFor resolves engines from and /api/models serves the picker
* /api/models serves the picker from — so the money channel's lookup table * from — so the money channel's lookup table can never disagree with the
* can never disagree with the engine. Fully offline (the JSON ships in * engine. Fully offline (the JSON ships in node_modules); refreshes happen
* node_modules); refreshes happen by bumping @earendil-works/pi-ai and * by bumping @earendil-works/pi-ai and re-running this script. The tables
* re-running this script. The tables are generated; hand edits are * are generated; hand edits are overwritten. A model missing from the
* overwritten. A model missing from the catalog gets no entry — cost * catalogs gets no entry — cost renders as absent ("—"), never zero.
* renders as absent ("—"), never zero. *
* Key format (model slots wave, 2026-09-20): uniformly provider-qualified
* ("provider:id", incl. "openrouter:<slug>"). Owner decision: no legacy
* compat — records stamped with pre-wave bare slugs simply render unpriced.
*/ */
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
const CATALOG = path.join( const DATA_DIR = path.join(
import.meta.dirname, import.meta.dirname,
"..", "..",
"node_modules", "node_modules",
@@ -26,11 +29,10 @@ const CATALOG = path.join(
"dist", "dist",
"providers", "providers",
"data", "data",
"openrouter.json",
); );
const OUT = path.join(import.meta.dirname, "..", "src", "core", "prices.ts"); const OUT = path.join(import.meta.dirname, "..", "src", "core", "prices.ts");
/** One raw catalog entry (fields we consume; the file carries more). */ /** One raw catalog entry (fields we consume; the files carry more). */
interface CatalogModel { interface CatalogModel {
id: string; id: string;
name: string; name: string;
@@ -39,34 +41,51 @@ interface CatalogModel {
} }
function main(): void { function main(): void {
// Keyed by api ("openai-completions" | "anthropic-messages"), each a // Every data file is {api: {modelId: entry}} — ids are per-provider
// Record<id, entry> — every OpenRouter id is unique across the two maps. // namespaced already (openrouter's are slugs, native ones plain).
const catalog = JSON.parse(fs.readFileSync(CATALOG, "utf-8")) as Record< const priced = new Map<string, { cost: CatalogModel["cost"]; contextWindow: number }>();
for (const file of fs.readdirSync(DATA_DIR).filter((f) => f.endsWith(".json"))) {
const provider = file.slice(0, -".json".length);
const catalog = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), "utf-8")) as Record<
string, string,
Record<string, CatalogModel> Record<string, CatalogModel>
>; >;
const byId = new Map<string, CatalogModel>(); for (const models of Object.values(catalog)) {
for (const api of Object.values(catalog)) for (const [id, m] of Object.entries(api)) byId.set(id, m); for (const [id, m] of Object.entries(models)) {
// A catalog entry without pricing/limit data gets no key at all
// (cost renders "—", never zero; the limit renders unknown).
if (!m.cost || m.contextWindow === undefined) continue;
// First writer wins (an id can appear under two apis within one
// file with identical pricing; cross-file collisions cannot
// happen — keys are provider-qualified).
priced.set(`${provider}:${id}`, { cost: m.cost, contextWindow: m.contextWindow });
}
}
}
// Sort by id for a stable, diff-friendly generated table. // Sort by key for a stable, diff-friendly generated table.
const priced: { price: string; limit: string }[] = [...byId.entries()] const rows = [...priced.entries()]
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([id, m]) => ({ .map(([key, m]) => ({
price: `\t${JSON.stringify(id)}: { input: ${m.cost.input}, output: ${m.cost.output}, cacheRead: ${m.cost.cacheRead}, cacheWrite: ${m.cost.cacheWrite} },`, price: `\t${JSON.stringify(key)}: { input: ${m.cost.input}, output: ${m.cost.output}, cacheRead: ${m.cost.cacheRead}, cacheWrite: ${m.cost.cacheWrite} },`,
limit: `\t${JSON.stringify(id)}: ${m.contextWindow},`, limit: `\t${JSON.stringify(key)}: ${m.contextWindow},`,
})); }));
const tables = `/** Token prices, USD per 1M tokens. A missing model makes cost render const tables = `/** Token prices, USD per 1M tokens. A missing model makes cost render
* as absent ("—"), never zero. Refresh: bump @earendil-works/pi-ai, then * as absent ("—"), never zero. Keys are provider-qualified ("provider:id")
* npx tsx scripts/sync-pricing.ts (reads pi-ai's shipped catalog — the * since the model slots wave; openrouter ids ALSO appear under their bare
* same source the engine and the picker use). */ * slug (the legacy stamp format, kept so pre-wave records keep pricing).
* Refresh: bump @earendil-works/pi-ai, then npx tsx scripts/sync-pricing.ts
* (reads pi-ai's shipped catalogs — the same source the engine and the
* picker use). */
export const PRICES: Record<string, ModelPrice> = { export const PRICES: Record<string, ModelPrice> = {
${priced.map((p) => p.price).join("\n")} ${rows.map((r) => r.price).join("\n")}
}; };
/** Context-window limits (tokens) per model — generated alongside PRICES. */ /** Context-window limits (tokens) per model — generated alongside PRICES,
* same key families. */
export const CONTEXT_LIMITS: Record<string, number> = { export const CONTEXT_LIMITS: Record<string, number> = {
${priced.map((p) => p.limit).join("\n")} ${rows.map((r) => r.limit).join("\n")}
}; };
`; `;
@@ -76,7 +95,7 @@ ${priced.map((p) => p.limit).join("\n")}
if (start === -1 || end === -1) throw new Error("prices.ts markers not found — file structure changed"); if (start === -1 || end === -1) throw new Error("prices.ts markers not found — file structure changed");
fs.writeFileSync(OUT, src.slice(0, start) + tables + "\n" + src.slice(end), "utf-8"); fs.writeFileSync(OUT, src.slice(0, start) + tables + "\n" + src.slice(end), "utf-8");
console.log(`prices.ts regenerated from pi-ai's catalog: ${priced.length} models priced.`); console.log(`prices.ts regenerated from pi-ai's catalogs: ${priced.size} keys priced.`);
} }
main(); main();
+44 -18
View File
@@ -6,7 +6,7 @@ import {
type Models, type Models,
type StreamFunction, type StreamFunction,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter"; import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { MODELS, type ModelRole } from "../../config.ts"; import { MODELS, type ModelRole } from "../../config.ts";
import type { ChatSettings } from "../core/contracts/chat.ts"; import type { ChatSettings } from "../core/contracts/chat.ts";
@@ -42,11 +42,18 @@ export function thinkingLevelFor(role: ModelRole): ThinkingLevel {
let collection: ReturnType<typeof createModels> | undefined; let collection: ReturnType<typeof createModels> | undefined;
/** The process-wide pi-ai Models collection (OpenRouter catalog). */ /** The process-wide pi-ai Models collection — EVERY builtin provider's
* catalog (model slots wave, 2026-09-20): engines and the picker resolve
* against all of pi-ai's per-provider catalogs, so a settings slot can pin
* any native model id. Auth is resolved per request by pi-ai itself from
* the environment (the keys seam reflects file-saved keys into env), and
* a provider with no key simply fails its stream with "not configured" —
* honest, late, and unreachable through the picker, which gates on
* keyPresent. */
export function piModels() { export function piModels() {
if (!collection) { if (!collection) {
collection = createModels(); collection = createModels();
collection.setProvider(openrouterProvider()); for (const provider of builtinProviders()) collection.setProvider(provider);
} }
return collection; return collection;
} }
@@ -56,23 +63,28 @@ export function openRouterApiKey(): string | undefined {
return process.env.OPENROUTER_API_KEY || undefined; return process.env.OPENROUTER_API_KEY || undefined;
} }
/** The production engine for one agent role. `settings` is the chat's /** A fully-resolved model pin — the shape every engine now comes from
* model-control override (prober/teacher only — sub-agent callers pass * (model slots wave, 2026-09-20): the settings-resolution chain's output.
* nothing and get config.ts's pin): any absent field falls back to the * Every field concrete; the CALLER resolved the chain (role override →
* config spec, and the thinking level is CLAMPED to the model's supported * slot → factory pin, or the chat's conversation chain). */
* list (pi's thinkingLevelMap — e.g. GLM-5.3-flash accepts low|high|max), export interface ResolvedModelSpec {
* so a settings change between models can never send an invalid level. */ provider: string;
export function piEngineFor(role: ModelRole, settings?: ChatSettings): PiEngine { id: string;
thinkingLevel: ThinkingLevel;
}
/** The production engine for a RESOLVED spec (model slots wave,
* 2026-09-20): provider-aware catalog lookup + thinking clamp. The API
* key is resolved by pi-ai itself from the environment per request (the
* keys seam reflects file-saved keys into env), so the engine never
* hardcodes OpenRouter's. */
export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
const models: Models = piModels(); const models: Models = piModels();
const spec = MODELS[role]; const model = models.getModel(spec.provider, spec.id);
const modelId = settings?.model?.id ?? spec.model;
const providerId = settings?.model?.provider ?? "openrouter";
const model = models.getModel(providerId, modelId);
if (!model) { if (!model) {
throw new Error(`${providerId} catalog has no model ${modelId} (role ${role})`); throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
} }
const requested = settings?.thinkingLevel ?? thinkingLevelFor(role); const level = clampThinkingLevel(model, spec.thinkingLevel);
const level = clampThinkingLevel(model, requested);
return { return {
model, model,
level, level,
@@ -83,7 +95,21 @@ export function piEngineFor(role: ModelRole, settings?: ChatSettings): PiEngine
models.streamSimple(requestModel, context, { models.streamSimple(requestModel, context, {
...options, ...options,
reasoning: level === "off" ? undefined : level, reasoning: level === "off" ? undefined : level,
apiKey: options?.apiKey ?? openRouterApiKey(),
}), }),
}; };
} }
/** The production engine for one agent role — the LEGACY fallback path:
* the chat's model-control override over config.ts's pin. Production
* callers (chat-handler) now resolve the full chain (chat override →
* strong slot → factory pin) and call piEngineForSpec; this stays for
* direct config-pin fallbacks (sub-agents without an injected engine —
* the job runner injects one — and tests). */
export function piEngineFor(role: ModelRole, settings?: ChatSettings): PiEngine {
const spec = MODELS[role];
return piEngineForSpec({
provider: settings?.model?.provider ?? spec.provider,
id: settings?.model?.id ?? spec.model,
thinkingLevel: settings?.thinkingLevel ?? thinkingLevelFor(role),
});
}
+4 -1
View File
@@ -293,7 +293,10 @@ async function runSubmitAgent<T>(opts: {
onAssistantMessage: (message, step) => onAssistantMessage: (message, step) =>
opts.onStep( opts.onStep(
message.usage, message.usage,
message.model, // Provider-qualified stamp (model slots wave, 2026-09-20):
// pi reports the bare id; usage stamps carry provider:id to
// match the price table's uniform keys.
`${message.provider}:${message.model}`,
step, step,
message.stopReason, message.stopReason,
message.content.filter((c) => c.type === "toolCall").map((c) => (c as { name: string }).name).join(", ") || "(none)", message.content.filter((c) => c.type === "toolCall").map((c) => (c as { name: string }).name).join(", ") || "(none)",
+53 -20
View File
@@ -19,7 +19,7 @@ import {
type VisualToolInput, type VisualToolInput,
} from "../../core/contracts.ts"; } from "../../core/contracts.ts";
import { registerJobAbort, unregisterJobAbort } from "./aborts.ts"; import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
import { MODELS } from "../../../config.ts"; import { MODELS, type ModelRole } from "../../../config.ts";
import type { ChatStore, JobStore } from "../../core/ports.ts"; import type { ChatStore, JobStore } from "../../core/ports.ts";
import { runDemoBuilder } from "./demo-builder.ts"; import { runDemoBuilder } from "./demo-builder.ts";
import { runPdfReaderAgent } from "./pdf-reader.ts"; import { runPdfReaderAgent } from "./pdf-reader.ts";
@@ -68,6 +68,14 @@ export interface JobRunnerDeps {
* flipped); rejects when delivery fails unrecoverably. * flipped); rejects when delivery fails unrecoverably.
*/ */
deliver: (steer: SubagentSteerMessage) => Promise<void>; deliver: (steer: SubagentSteerMessage) => Promise<void>;
/**
* Settings-resolved engine per role (model slots wave, 2026-09-20): the
* role-override → slot → factory-pin chain, read FRESH per job (the
* server wiring passes effectiveSettings-based resolution). Closes the
* old gap where per-role settings overrides never reached job engines.
* Test overrides (below) still win; absent → config pins.
*/
engineFor?: (role: ModelRole) => PiEngine;
} }
export interface JobRunner { export interface JobRunner {
@@ -89,18 +97,26 @@ interface LoopContext {
onUsage: (step: UsageStepLike) => void; onUsage: (step: UsageStepLike) => void;
} }
/** The OpenRouter model id for a job kind — JobUsage.model. The kind→role /** The kind→role table (model slots wave, 2026-09-20): which ModelRole's
* table mirrors MODELS in config.ts (research→researcher, demo→demoBuilder, * engine each sub-agent kind runs — the engineFor injection key. */
* visual→visualMaker); the id is taken from config so a price-table sync const KIND_ROLE: Record<Exclude<JobKind, "graph-context">, ModelRole> = {
* re-prices jobs for free. Model overrides (tests) still bill under the research: "researcher",
* configured model — usage is what is being captured, not which instance. demo: "demoBuilder",
* Context-injection jobs make NO model call ("" — onUsage never fires for visual: "visualMaker",
* them; the kind is deliberately absent from the runners below). */ pdf: "pdfReader",
};
/** The config-pin model stamp for a job kind — JobUsage.model when no
* engine was resolved (engineFor absent AND no test override). Stamps are
* provider-qualified ("provider:id") since the model slots wave, matching
* the price table's uniform keys. Context-injection jobs make NO model
* call ("" — onUsage never fires for them; the kind is deliberately
* absent from the runners below). */
const KIND_MODEL: Record<JobKind, string> = { const KIND_MODEL: Record<JobKind, string> = {
research: MODELS.researcher.model, research: `${MODELS.researcher.provider}:${MODELS.researcher.model}`,
demo: MODELS.demoBuilder.model, demo: `${MODELS.demoBuilder.provider}:${MODELS.demoBuilder.model}`,
visual: MODELS.visualMaker.model, visual: `${MODELS.visualMaker.provider}:${MODELS.visualMaker.model}`,
pdf: MODELS.pdfReader.model, pdf: `${MODELS.pdfReader.provider}:${MODELS.pdfReader.model}`,
"graph-context": "", "graph-context": "",
}; };
@@ -111,8 +127,10 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
* then writes through the job store so the poll sees progress). */ * then writes through the job store so the poll sees progress). */
const usageTotals = new Map<string, JobUsage>(); const usageTotals = new Map<string, JobUsage>();
function makeLoopContext(jobId: string, kind: JobKind): LoopContext { function makeLoopContext(jobId: string, kind: JobKind, engine?: PiEngine): LoopContext {
const model = KIND_MODEL[kind]; // Provider-qualified stamp (model slots wave): the engine's actual
// model, else the legacy config-pin id (see KIND_MODEL).
const model = engine ? `${engine.model.provider}:${engine.model.id}` : KIND_MODEL[kind];
// Real-kill wiring (2026-09 abort semantics): the controller is what // Real-kill wiring (2026-09 abort semantics): the controller is what
// the abort endpoint fires (agent/subagents/aborts.ts registry); the // the abort endpoint fires (agent/subagents/aborts.ts registry); the
// signal rides into every model call of the loop. // signal rides into every model call of the loop.
@@ -143,14 +161,28 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
* them below rather than guessing wrong. */ * them below rather than guessing wrong. */
// Each kind's runner narrows the union input itself (dispatch-time inputs // Each kind's runner narrows the union input itself (dispatch-time inputs
// are kind-correct by construction; transcript fallbacks are cast after). // are kind-correct by construction; transcript fallbacks are cast after).
/** The engine one job runs on: test override first (the old seam —
* tests swap scripted engines without touching deps), then the injected
* settings-resolved engineFor (model slots wave, 2026-09-20), else
* undefined → the sub-agent factories fall back to config pins. */
function pickEngine(kind: Exclude<JobKind, "graph-context">): PiEngine | undefined {
const testOverride: Record<string, { current?: PiEngine } | undefined> = {
researcher: researchModelOverride,
demoBuilder: demoModelOverride,
visualMaker: visualModelOverride,
pdfReader: pdfModelOverride,
};
return testOverride[KIND_ROLE[kind]]?.current ?? deps.engineFor?.(KIND_ROLE[kind]);
}
const KIND_RUNNERS: Record< const KIND_RUNNERS: Record<
Exclude<JobKind, "graph-context">, Exclude<JobKind, "graph-context">,
(jobId: string, input: JobToolInput | undefined, loop: LoopContext) => Promise<void> (jobId: string, engine: PiEngine | undefined, input: JobToolInput | undefined, loop: LoopContext) => Promise<void>
> = { > = {
research: (jobId, input, loop) => runResearchJob(jobId, researchModelOverride.current, input, loop), research: (jobId, engine, input, loop) => runResearchJob(jobId, engine, input, loop),
demo: (jobId, input, loop) => runDemoJob(jobId, demoModelOverride.current, input, loop), demo: (jobId, engine, input, loop) => runDemoJob(jobId, engine, input, loop),
visual: (jobId, input, loop) => runVisualJob(jobId, visualModelOverride.current, input, loop), visual: (jobId, engine, input, loop) => runVisualJob(jobId, engine, input, loop),
pdf: (jobId, input, loop) => runPdfJob(jobId, pdfModelOverride.current, input, loop), pdf: (jobId, engine, input, loop) => runPdfJob(jobId, engine, input, loop),
}; };
async function runJob(jobId: string, input?: JobToolInput): Promise<void> { async function runJob(jobId: string, input?: JobToolInput): Promise<void> {
@@ -173,7 +205,8 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
return; return;
} }
try { try {
await run(jobId, input, makeLoopContext(jobId, job.kind)); const engine = pickEngine(job.kind);
await run(jobId, engine, input, makeLoopContext(jobId, job.kind, engine));
} finally { } finally {
unregisterJobAbort(jobId); unregisterJobAbort(jobId);
// The last usage write may have been mid-flight when the loop died; // The last usage write may have been mid-flight when the loop died;
+4 -2
View File
@@ -86,7 +86,8 @@ export const GRAPH_CONTEXT_TOKENS_STOPS = [0, 64, 256, 1024, 4096, 16384] as con
* billed (CallUsage per agent step). Steps, not a sum: cost and context * billed (CallUsage per agent step). Steps, not a sum: cost and context
* folds happen at read time (core/prices.ts). */ * folds happen at read time (core/prices.ts). */
export interface ExtractionStageUsage { export interface ExtractionStageUsage {
/** The OpenRouter model id that ran this stage. */ /** The model that ran this stage, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
model: string; model: string;
steps: CallUsage[]; steps: CallUsage[];
} }
@@ -142,7 +143,8 @@ export interface TurnUsage {
* entry to the transcript (the block this turn "belongs" to). */ * entry to the transcript (the block this turn "belongs" to). */
messageId: string; messageId: string;
at: string; at: string;
/** The OpenRouter model id that ran the turn. */ /** The model that ran the turn, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
model: string; model: string;
/** One entry per agent step (model call) in this turn. */ /** One entry per agent step (model call) in this turn. */
steps: CallUsage[]; steps: CallUsage[];
+2 -1
View File
@@ -41,7 +41,8 @@ export interface JobRecord {
* field NO model call reported stays undefined — absent is not zero (the * field NO model call reported stays undefined — absent is not zero (the
* money-channel doctrine; cost renders "—", never $0). */ * money-channel doctrine; cost renders "—", never $0). */
export interface JobUsage { export interface JobUsage {
/** The OpenRouter model id that ran the sub-agent. */ /** The model that ran the sub-agent, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
model: string; model: string;
/** Running totals summed over the run's model calls so far. */ /** Running totals summed over the run's model calls so far. */
inputTokens: number | undefined; inputTokens: number | undefined;
+26 -1
View File
@@ -6,7 +6,7 @@
* components never import from the server band). * components never import from the server band).
*/ */
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { BackgroundModelRole } from "../settings.ts"; import type { BackgroundModelRole, ModelSlotName } from "../settings.ts";
/** One picker row: everything the UI shows about a model, projected from /** One picker row: everything the UI shows about a model, projected from
* pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same * pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same
@@ -42,6 +42,12 @@ export type RolePins = Record<
{ model: { provider: string; id: string }; thinkingLevel?: ThinkingLevel } { model: { provider: string; id: string }; thinkingLevel?: ThinkingLevel }
>; >;
/** One slot tier's RESOLVED pin (model slots wave, 2026-09-20): the
* strong/cheap/vision tier's concrete model — slot override falling back
* to the factory rep pin. The settings UI captions each slot row with it
* and the picker pre-fills from it. */
export type SlotPins = Record<ModelSlotName, { model: { provider: string; id: string }; thinkingLevel?: ThinkingLevel }>;
/** GET /api/models response. */ /** GET /api/models response. */
export interface ModelCatalogResponse { export interface ModelCatalogResponse {
models: ModelCatalogEntry[]; models: ModelCatalogEntry[];
@@ -49,4 +55,23 @@ export interface ModelCatalogResponse {
/** The six background roles' resolved pins (same resolution the agent /** The six background roles' resolved pins (same resolution the agent
* factories use — src/server/stores.ts effectiveSettings). */ * factories use — src/server/stores.ts effectiveSettings). */
rolePins: RolePins; rolePins: RolePins;
/** The three slot tiers' resolved pins (model slots wave, 2026-09-20):
* same resolution chain — role override → slot → factory pin — reported
* at tier granularity; effectiveSettings().models verbatim. */
slots: SlotPins;
/** Providers with a PRESENT key, and which env var gates each — the
* model slots wave's federation surface (2026-09-20): the picker lists
* models from these providers' pi-ai catalogs only. Keyless providers
* are simply absent (the Keys page is the discovery surface, same
* discipline as the search engines). */
providers: CatalogProvider[];
}
/** One row of the providers surface. */
export interface CatalogProvider {
id: string;
/** The env var whose presence federates this provider in. */
envVar: string;
/** Whether that var is currently present (env or auth file). */
keyPresent: boolean;
} }
+2716 -736
View File
File diff suppressed because it is too large Load Diff
+88 -10
View File
@@ -23,23 +23,40 @@
*/ */
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { ModelRole } from "../../config.ts";
import type { ChatSettings } from "./contracts/chat.ts"; import type { ChatSettings } from "./contracts/chat.ts";
import type { SearchEngineId } from "./search.ts"; import type { SearchEngineId } from "./search.ts";
import { import {
BACKGROUND_MODEL_ROLES, BACKGROUND_MODEL_ROLES,
FACTORY_SETTINGS, FACTORY_SETTINGS,
MODEL_SLOTS,
ROLE_SLOT,
type AgentSettings, type AgentSettings,
type BackgroundModelRole, type BackgroundModelRole,
type FactorySettings, type FactorySettings,
type GeneralSettings, type GeneralSettings,
type GraphSettings, type GraphSettings,
type ModelSlot,
type ModelSlotName,
type RoleModelOverride, type RoleModelOverride,
type UserSettings, type UserSettings,
} from "./settings.ts"; } from "./settings.ts";
/** The factory layer for model roles — config.ts's MODELS shape (types only /** The factory layer for model roles — config.ts's MODELS shape (types only
* here; the runtime object is threaded in by the caller). */ * here; the runtime object is threaded in by the caller). Model slots
export type ModelRoleSpecs = Record<BackgroundModelRole, { model: string; effort: ThinkingLevel }>; * (2026-09-20): the factory map spans ALL nine roles (the strong slot's
* factory fallback is the prober pin), while per-role OVERRIDES stay
* background-only (the conversation pair is chat-settings-controlled). */
export type ModelRoleSpecs = Record<ModelRole, { provider: string; model: string; effort: ThinkingLevel }>;
/** A fully-resolved model slot: the tier's concrete model (slot over factory
* rep pin) — strong's factory rep is the prober pin, cheap's the researcher
* pin, vision's the visualMaker pin (owner-settled tier mapping). */
export interface EffectiveSlot {
provider: string;
id: string;
thinkingLevel: ThinkingLevel;
}
/** A fully-resolved model pin for one background role. */ /** A fully-resolved model pin for one background role. */
export interface EffectiveModelSpec { export interface EffectiveModelSpec {
@@ -68,23 +85,78 @@ export interface EffectiveSettings {
* lives in server/search.ts, where keys are visible. */ * lives in server/search.ts, where keys are visible. */
search: { engine?: SearchEngineId }; search: { engine?: SearchEngineId };
}; };
/** The resolved strong/cheap/vision slots (model slots wave,
* 2026-09-20): each tier's concrete model (slot over factory rep pin)
* — the per-role chain (override → slot → pin) is resolved into
* agent.models; these are the tier-level views the picker captions. */
models: Record<ModelSlotName, EffectiveSlot>;
appearance: EffectiveAppearance; appearance: EffectiveAppearance;
graph: Required<GraphSettings>; graph: Required<GraphSettings>;
} }
/** Resolve one background role's spec: record override per field, /** Full-chain resolution for ANY role (model slots wave, 2026-09-20):
* config.ts pin per absent field (provider pinned to openrouter today). */ * per-role override (background roles only) → slot → factory pin. The
* conversation pair (prober/teacher) resolves the same chain MINUS the
* role override (their override is the chat's own settings — the
* chat-handler overlays it itself). This is the engine wiring's one call. */
export function resolveRoleSpec(
role: ModelRole,
record: UserSettings | null,
factory: ModelRoleSpecs,
): EffectiveModelSpec {
const bg = BACKGROUND_MODEL_ROLES as readonly string[];
const override = bg.includes(role) ? record?.agent?.models?.[role as BackgroundModelRole] : undefined;
return resolveModelSpec(role as BackgroundModelRole, override, factory, record?.models);
}
/** The factory representative pin for a slot tier — the pin the slot
* falls back to when unset (owner-settled mapping: strong's rep is the
* prober pin — prober and teacher share the strong tier — cheap's the
* researcher pin, vision's the visualMaker pin). */
export function factorySlotRep(slot: ModelSlotName, factory: ModelRoleSpecs): EffectiveSlot {
const rep =
slot === "strong" ? factory.prober : slot === "cheap" ? factory.researcher : factory.visualMaker;
// The factory contract spans all nine roles (stores.ts MODEL_PINS) —
// the rep lookup cannot miss; the cast is for the checker only.
const r = rep as { provider: string; model: string; effort: ThinkingLevel };
return { provider: r.provider, id: r.model, thinkingLevel: r.effort };
}
/** Resolve one slot: the slot override per field, the factory rep pin per
* absent field (model slots wave, 2026-09-20). */
export function resolveSlot(
slot: ModelSlotName,
override: ModelSlot | undefined,
factory: ModelRoleSpecs,
): EffectiveSlot {
const rep = factorySlotRep(slot, factory);
return {
provider: override?.model?.provider ?? rep.provider,
id: override?.model?.id ?? rep.id,
thinkingLevel: override?.thinkingLevel ?? rep.thinkingLevel,
};
}
/** Resolve one background role's spec — the model slots chain (owner
* decision 2026-09-20): role override → SLOT → factory pin, each field
* independent (a role can borrow only the slot's model, or only its
* thinking budget). An absent provider coordinate anywhere resolves to
* the factory pin's provider (the BYOK default, openrouter today) —
* legacy ids without a provider ride the factory provider. */
export function resolveModelSpec( export function resolveModelSpec(
role: BackgroundModelRole, role: BackgroundModelRole,
override: RoleModelOverride | undefined, override: RoleModelOverride | undefined,
factory: ModelRoleSpecs, factory: ModelRoleSpecs,
slots?: Partial<Record<ModelSlotName, ModelSlot>>,
): EffectiveModelSpec { ): EffectiveModelSpec {
const spec = factory[role]; // The factory contract spans all nine roles; background roles always hit.
const model = override?.model; const spec = factory[role] as { provider: string; model: string; effort: ThinkingLevel };
const slotName = ROLE_SLOT[role] as ModelSlotName | undefined;
const slot = slotName ? slots?.[slotName] : undefined;
return { return {
provider: model?.provider ?? "openrouter", provider: override?.model?.provider ?? slot?.model?.provider ?? spec.provider,
id: model?.id ?? spec.model, id: override?.model?.id ?? slot?.model?.id ?? spec.model,
thinkingLevel: override?.thinkingLevel ?? spec.effort, thinkingLevel: override?.thinkingLevel ?? slot?.thinkingLevel ?? spec.effort,
}; };
} }
@@ -92,6 +164,7 @@ export function resolveModelSpec(
* field then lands on its factory default. */ * field then lands on its factory default. */
export function resolveSettings(record: UserSettings | null, factory: ModelRoleSpecs): EffectiveSettings { export function resolveSettings(record: UserSettings | null, factory: ModelRoleSpecs): EffectiveSettings {
const agent = record?.agent ?? {}; const agent = record?.agent ?? {};
const modelsSection = record?.models ?? {};
const appearance = record?.appearance ?? {}; const appearance = record?.appearance ?? {};
const md = appearance.mdRendering ?? {}; const md = appearance.mdRendering ?? {};
const graph = record?.graph ?? {}; const graph = record?.graph ?? {};
@@ -99,7 +172,7 @@ export function resolveSettings(record: UserSettings | null, factory: ModelRoleS
const factoryMd = factoryAppearance.mdRendering; const factoryMd = factoryAppearance.mdRendering;
const models = {} as Record<BackgroundModelRole, EffectiveModelSpec>; const models = {} as Record<BackgroundModelRole, EffectiveModelSpec>;
for (const role of BACKGROUND_MODEL_ROLES) { for (const role of BACKGROUND_MODEL_ROLES) {
models[role] = resolveModelSpec(role, agent.models?.[role], factory); models[role] = resolveModelSpec(role, agent.models?.[role], factory, modelsSection);
} }
return { return {
general: { general: {
@@ -112,6 +185,11 @@ export function resolveSettings(record: UserSettings | null, factory: ModelRoleS
prompts: agent.prompts ?? {}, prompts: agent.prompts ?? {},
search: agent.search ?? {}, search: agent.search ?? {},
}, },
models: {
strong: resolveSlot("strong", modelsSection.strong, factory),
cheap: resolveSlot("cheap", modelsSection.cheap, factory),
vision: resolveSlot("vision", modelsSection.vision, factory),
},
appearance: { appearance: {
mode: appearance.mode ?? factoryAppearance.mode, mode: appearance.mode ?? factoryAppearance.mode,
background: appearance.background ?? factoryAppearance.background, background: appearance.background ?? factoryAppearance.background,
+63
View File
@@ -57,6 +57,51 @@ export interface RoleModelOverride {
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
} }
// ─── Model slots (model slots wave, 2026-09-20) ──────────────────────────────
/** The capability tiers a user can pin models into (owner-settled mapping):
* strong = prober + teacher (also the per-session default-teacher
* starting point); cheap = researcher, demo-builder, extractor, auditor,
* labeler; vision = visual-maker + pdf-reader. Resolution per role:
* ROLE OVERRIDE → slot → factory pin. */
export const MODEL_SLOTS = ["strong", "cheap", "vision"] as const;
export type ModelSlotName = (typeof MODEL_SLOTS)[number];
/** One slot's override — same per-field independence as a role override:
* absent model keeps the tier's current model, absent thinkingLevel keeps
* its thinking budget. */
export interface ModelSlot {
/** The model swap: both coordinates or neither. */
model?: { provider: string; id: string };
/** The thinking budget swap. */
thinkingLevel?: ThinkingLevel;
}
/** The `models` settings section (owner decision 2026-09-20: a dedicated
* section — slots are engine-level choices, not one session's). All slots
* optional: an absent slot means every role of its tier runs whatever the
* deeper tiers of the chain resolve to (per-role override → factory pin). */
export interface ModelsSettings {
strong?: ModelSlot;
cheap?: ModelSlot;
vision?: ModelSlot;
}
/** Which slot a role draws from (model slots wave, 2026-09-20). Includes the
* conversation pair and pdfReader (not BackgroundModelRoles — the slot
* tier spans every role; only background roles surface per-role overrides). */
export const ROLE_SLOT: Readonly<Record<string, ModelSlotName>> = {
prober: "strong",
teacher: "strong",
researcher: "cheap",
demoBuilder: "cheap",
extractor: "cheap",
auditor: "cheap",
labeler: "cheap",
visualMaker: "vision",
pdfReader: "vision",
};
/** General settings — app-level behavior. Version DISPLAY and plans login /** General settings — app-level behavior. Version DISPLAY and plans login
* live outside the record (package.json / auth.json) by owner decision. */ * live outside the record (package.json / auth.json) by owner decision. */
export interface GeneralSettings { export interface GeneralSettings {
@@ -140,6 +185,8 @@ export interface UserSettings {
* controls. NOT shown in the settings panel (owner decision). */ * controls. NOT shown in the settings panel (owner decision). */
chat?: ChatSettings; chat?: ChatSettings;
agent?: AgentSettings; agent?: AgentSettings;
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
models?: ModelsSettings;
appearance?: AppearanceSettings; appearance?: AppearanceSettings;
graph?: GraphSettings; graph?: GraphSettings;
[key: string]: unknown; [key: string]: unknown;
@@ -244,6 +291,18 @@ function sanitizeRoleOverride(raw: unknown): RoleModelOverride | undefined {
return Object.keys(out).length ? out : undefined; return Object.keys(out).length ? out : undefined;
} }
/** The models section (model slots wave, 2026-09-20): each slot keeps
* only well-formed parts — the same shape rules as a role override, so a
* stale client can't park junk in a slot. */
function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
const out: ModelsSettings = {};
for (const slot of MODEL_SLOTS) {
const cleaned = sanitizeRoleOverride(raw[slot]);
if (cleaned) out[slot] = cleaned;
}
return out;
}
function sanitizeBooleanMap(raw: unknown): Record<string, boolean> | undefined { function sanitizeBooleanMap(raw: unknown): Record<string, boolean> | undefined {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const out: Record<string, boolean> = {}; const out: Record<string, boolean> = {};
@@ -348,6 +407,10 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
if (r.appearance !== null && typeof r.appearance === "object" && !Array.isArray(r.appearance)) { if (r.appearance !== null && typeof r.appearance === "object" && !Array.isArray(r.appearance)) {
out.appearance = sanitizeAppearance(r.appearance as Record<string, unknown>); out.appearance = sanitizeAppearance(r.appearance as Record<string, unknown>);
} }
if (r.models !== null && typeof r.models === "object" && !Array.isArray(r.models)) {
const models = sanitizeModels(r.models as Record<string, unknown>);
if (Object.keys(models).length) out.models = models;
}
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) { if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
out.graph = sanitizeGraph(r.graph as Record<string, unknown>); out.graph = sanitizeGraph(r.graph as Record<string, unknown>);
} }
+21 -5
View File
@@ -1,7 +1,7 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import type { Usage } from "@earendil-works/pi-ai"; import type { Usage } from "@earendil-works/pi-ai";
import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type TeachingAgent } from "../agent/teacher-agent.ts"; import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type TeachingAgent } from "../agent/teacher-agent.ts";
import { piEngineFor } from "../agent/models.ts"; import { piEngineForSpec } from "../agent/models.ts";
import { createJobRunner } from "../agent/subagents/runner.ts"; import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts"; import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import { runGraphContextJob, type GraphContextDeps } from "../agent/subagents/graph-context.ts"; import { runGraphContextJob, type GraphContextDeps } from "../agent/subagents/graph-context.ts";
@@ -10,8 +10,8 @@ import type { ModelRole } from "../../config.ts";
import { claimSteers, deliverJobResult, setContextRespawn, setJobRespawn, setTurnRunner } from "./jobs.ts"; import { claimSteers, deliverJobResult, setContextRespawn, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { recordToPiMessages } from "./pi/record-adapter.ts"; import { recordToPiMessages } from "./pi/record-adapter.ts";
import { createPiTurnResponse } from "./pi/ui-stream.ts"; import { createPiTurnResponse } from "./pi/ui-stream.ts";
import { effectiveSettings, stores } from "./stores.ts"; import { effectiveSettings, MODEL_PINS, stores } from "./stores.ts";
import { extractionEnabled, graphContextEnabled } from "../core/settings-resolution.ts"; import { extractionEnabled, graphContextEnabled, resolveRoleSpec } from "../core/settings-resolution.ts";
import { beginTurn, endTurn } from "./turn-liveness.ts"; import { beginTurn, endTurn } from "./turn-liveness.ts";
import { persistTurn, triggerExtraction, triggerGraphContext, withMessageIds } from "./turn-service.ts"; import { persistTurn, triggerExtraction, triggerGraphContext, withMessageIds } from "./turn-service.ts";
import type { CallUsage } from "../core/contracts.ts"; import type { CallUsage } from "../core/contracts.ts";
@@ -39,6 +39,12 @@ const jobRunner = createJobRunner({
chatStore: stores.chatStore, chatStore: stores.chatStore,
jobStore: stores.jobStore, jobStore: stores.jobStore,
deliver: deliverJobResult, deliver: deliverJobResult,
// Model slots wave (2026-09-20): job engines resolve the full chain —
// role override → slot → factory pin — read FRESH per job (a settings
// PUT between dispatch and spawn lands). Closes the old gap where
// per-role overrides reached rolePins but never the engines.
engineFor: (role) =>
piEngineForSpec(resolveRoleSpec(role, stores.settingsStore.readSettings(), MODEL_PINS)),
}); });
/** The app's ONE extraction pipeline instance — both front doors share it: /** The app's ONE extraction pipeline instance — both front doors share it:
* quiz-triggered session runs (turn-service) and notes imports * quiz-triggered session runs (turn-service) and notes imports
@@ -205,8 +211,18 @@ export async function handleChatRequest(
// for the conversation agents; piEngineFor falls back to config on absent // for the conversation agents; piEngineFor falls back to config on absent
// fields and clamps the thinking level to the model's supported list). // fields and clamps the thinking level to the model's supported list).
const settings = stores.chatStore.getChat(chatId)?.settings; const settings = stores.chatStore.getChat(chatId)?.settings;
const engine = piEngineFor(role, settings); // Model slots wave (2026-09-20): the conversation chain is the chat's
const modelId = engine.model.id; // own override over the STRONG slot (prober + teacher's tier) over the
// factory pin — each field independent, same discipline as the roles.
const strong = effective.models.strong;
const engine = piEngineForSpec({
provider: settings?.model?.provider ?? strong.provider,
id: settings?.model?.id ?? strong.id,
thinkingLevel: settings?.thinkingLevel ?? strong.thinkingLevel,
});
// Provider-qualified stamp (model slots wave): matches the price table's
// provider:id keys.
const modelId = `${engine.model.provider}:${engine.model.id}`;
// Tests inject a factory: the agent is stateless per turn (docs/ // Tests inject a factory: the agent is stateless per turn (docs/
// pi-engine-swap.md §2), so each request builds a fresh one off the // pi-engine-swap.md §2), so each request builds a fresh one off the
// transcript — the factory mirrors that seam exactly. // transcript — the factory mirrors that seam exactly.
+104 -10
View File
@@ -16,19 +16,22 @@
import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai"; import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { MODELS } from "../../config.ts";
import { piModels } from "../agent/models.ts"; import { piModels } from "../agent/models.ts";
import { settingsStore } from "../store/settings.ts"; import { settingsStore } from "../store/settings.ts";
import { effectiveSettings } from "./stores.ts"; import { effectiveSettings } from "./stores.ts";
import { keyValue } from "./keys.ts";
import type { ChatSettings } from "../core/contracts/chat.ts"; import type { ChatSettings } from "../core/contracts/chat.ts";
import type { import type {
CatalogDefaults, CatalogDefaults,
CatalogProvider,
ModelCatalogEntry, ModelCatalogEntry,
ModelCatalogResponse, ModelCatalogResponse,
RolePins, RolePins,
SlotPins,
} from "../core/contracts/models.ts"; } from "../core/contracts/models.ts";
import type { EffectiveModelSpec } from "../core/settings-resolution.ts"; import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
import { BACKGROUND_MODEL_ROLES, type BackgroundModelRole } from "../core/settings.ts"; import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts";
import { BACKGROUND_MODEL_ROLES, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */ /** The picker's client-safe model entry (contract: core/contracts/models.ts). */
export type ModelDto = ModelCatalogEntry; export type ModelDto = ModelCatalogEntry;
@@ -37,10 +40,10 @@ export type ModelDto = ModelCatalogEntry;
* chat section in, CatalogDefaults out; `catalogDefaults()` below is the * chat section in, CatalogDefaults out; `catalogDefaults()` below is the
* fs wrapper). Absent fields (or no record — BYOK first boot) fall back * fs wrapper). Absent fields (or no record — BYOK first boot) fall back
* to the config pin, exactly as before. */ * to the config pin, exactly as before. */
export function catalogDefaultsFor(chat?: ChatSettings): CatalogDefaults { export function catalogDefaultsFor(chat: ChatSettings | undefined, strong: EffectiveSlot): CatalogDefaults {
return { return {
model: chat?.model ?? { provider: "openrouter", id: MODELS.prober.model }, model: chat?.model ?? { provider: strong.provider, id: strong.id },
thinkingLevel: (chat?.thinkingLevel ?? MODELS.prober.effort) as ThinkingLevel, thinkingLevel: chat?.thinkingLevel ?? strong.thinkingLevel,
}; };
} }
@@ -49,7 +52,7 @@ export function catalogDefaultsFor(chat?: ChatSettings): CatalogDefaults {
* the settings record's `chat` section; the picker's defaults row shows * the settings record's `chat` section; the picker's defaults row shows
* it once chosen. */ * it once chosen. */
export function catalogDefaults(): CatalogDefaults { export function catalogDefaults(): CatalogDefaults {
return catalogDefaultsFor(settingsStore.readSettings()?.chat); return catalogDefaultsFor(settingsStore.readSettings()?.chat, effectiveSettings().models.strong);
} }
/** Project the resolved per-role specs to the client DTO (pure — the /** Project the resolved per-role specs to the client DTO (pure — the
@@ -77,6 +80,42 @@ export function catalogRolePins(): RolePins {
return rolePinsFor(effectiveSettings().agent.models); return rolePinsFor(effectiveSettings().agent.models);
} }
/** The three slot tiers' RESOLVED pins (model slots wave, 2026-09-20) —
* effectiveSettings().models verbatim: slot over factory rep pin, the
* same resolution the engine wiring uses, so the Models settings page's
* captions cannot drift from what actually runs. */
export function catalogSlots(): SlotPins {
const slots = effectiveSettings().models;
const pins = {} as SlotPins;
for (const name of ["strong", "cheap", "vision"] as const) {
const slot: EffectiveSlot = slots[name];
pins[name] = {
model: { provider: slot.provider, id: slot.id },
thinkingLevel: slot.thinkingLevel,
};
}
return pins;
}
/** The providers surface (model slots wave, 2026-09-20): one row per
* plain-key model provider (core/auth-keys.ts's pi-ai-derived table),
* with keyPresent read env-wins through the keys seam, fresh per call —
* saving a key immediately federates the provider into the picker. */
export function catalogProviders(): CatalogProvider[] {
return AUTH_PROVIDER_KEYS.map((p) => ({
id: p.id,
envVar: p.envVar,
keyPresent: keyValue(p.envVar) !== undefined,
})).sort((a, b) => a.id.localeCompare(b.id));
}
/** The providers whose key is present — the picker federates exactly
* these (a keyless provider's models are unreachable by design; the
* Keys page is the discovery surface, same discipline as search). */
function keyedProviderIds(): Set<string> {
return new Set(catalogProviders().filter((p) => p.keyPresent).map((p) => p.id));
}
/** Project one catalog model to the client DTO. */ /** Project one catalog model to the client DTO. */
export function toModelDto(model: Model<Api>): ModelDto { export function toModelDto(model: Model<Api>): ModelDto {
return { return {
@@ -102,12 +141,20 @@ export function toModelDto(model: Model<Api>): ModelDto {
* defaults a settings-less chat runs + the background roles' resolved * defaults a settings-less chat runs + the background roles' resolved
* pins (the settings UI's per-role default captions). */ * pins (the settings UI's per-role default captions). */
export function modelCatalog(): ModelCatalogResponse { export function modelCatalog(): ModelCatalogResponse {
const keyed = keyedProviderIds();
const models = piModels() const models = piModels()
.getProviders() .getProviders()
.filter((p) => keyed.has(p.id))
.flatMap((p) => piModels().getModels(p.id)) .flatMap((p) => piModels().getModels(p.id))
.map(toModelDto) .map(toModelDto)
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); .sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
return { models, defaults: catalogDefaults(), rolePins: catalogRolePins() }; return {
models,
defaults: catalogDefaults(),
rolePins: catalogRolePins(),
slots: catalogSlots(),
providers: catalogProviders(),
};
} }
/** One validated outcome for a settings write. */ /** One validated outcome for a settings write. */
@@ -133,8 +180,11 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation
} }
} }
if (settings.thinkingLevel !== undefined) { if (settings.thinkingLevel !== undefined) {
const provider = settings.model?.provider ?? "openrouter"; // The conversation chain's fallback is the strong slot (model slots
const id = settings.model?.id ?? MODELS.prober.model; // wave, 2026-09-20) — the model a settings-less chat would run.
const strong = effectiveSettings().models.strong;
const provider = settings.model?.provider ?? strong.provider;
const id = settings.model?.id ?? strong.id;
const model = piModels().getModel(provider, id); const model = piModels().getModel(provider, id);
if (!model) return { ok: false, error: `No model "${id}" in the ${provider} catalog` }; if (!model) return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
const supported = getSupportedThinkingLevels(model); const supported = getSupportedThinkingLevels(model);
@@ -148,3 +198,47 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation
} }
return { ok: true }; return { ok: true };
} }
/** Validate a models-section write (PUT /api/settings/models) against the
* catalog — the same gate the chat section gets. Model swaps must name a
* real provider+model; a thinkingLevel must be supported by the model the
* slot would resolve to (the slot's own model, else the factory rep pin).
* keyPresent is NOT gated here (a stored pin for a provider whose key
* lapsed stays valid — it fails honestly at stream time, and the picker
* stops offering it). */
export function validateModelsSettings(settings: ModelsSettings): SettingsValidation {
for (const name of ["strong", "cheap", "vision"] as const) {
const slot = settings[name];
if (!slot) continue;
const verdict = validateSlotSpec(name, slot);
if (!verdict.ok) return verdict;
}
return { ok: true };
}
function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: string; id: string }; thinkingLevel?: ThinkingLevel }): SettingsValidation {
const providers = piModels().getProviders().map((p) => p.id);
if (slot.model) {
const { provider, id } = slot.model;
if (!providers.includes(provider)) {
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
}
if (!piModels().getModel(provider, id)) {
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
}
}
if (slot.thinkingLevel !== undefined) {
const provider = slot.model?.provider ?? effectiveSettings().models[name].provider;
const id = slot.model?.id ?? effectiveSettings().models[name].id;
const model = piModels().getModel(provider, id);
if (!model) return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
const supported = getSupportedThinkingLevels(model);
if (!supported.includes(slot.thinkingLevel)) {
return {
ok: false,
error: `${id} does not support thinking level "${slot.thinkingLevel}"`,
supported,
};
}
}
return { ok: true };
}
+28 -1
View File
@@ -19,9 +19,10 @@ import type {
BackgroundModelRole, BackgroundModelRole,
GeneralSettings, GeneralSettings,
GraphSettings, GraphSettings,
ModelsSettings,
RoleModelOverride, RoleModelOverride,
} from "../core/settings.ts"; } from "../core/settings.ts";
import { BACKGROUND_MODEL_ROLES } from "../core/settings.ts"; import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS } from "../core/settings.ts";
import { TOGGLEABLE_TOOLS } from "../core/pending.ts"; import { TOGGLEABLE_TOOLS } from "../core/pending.ts";
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */ /** Parsed-or-rejected — `ok: false` carries the 400's error message. */
@@ -292,6 +293,31 @@ export function parseGraphSettingsBody(body: unknown): SettingsBodyResult<GraphS
return { ok: true, settings }; return { ok: true, settings };
} }
/** models: { strong?, cheap?, vision? } — the slot tiers (model slots
* wave, 2026-09-20). Each slot is the same shape as a role override; a
* present-but-empty slot object is rejected (absent slot = unset; say
* what you mean). Catalog validity is checked by the route
* (validateChatSettings discipline). */
export function parseModelsSettingsBody(body: unknown): SettingsBodyResult<ModelsSettings> {
const raw = asRecord(body);
if (!raw) return { ok: false, error: "Invalid JSON body" };
const settings: ModelsSettings = {};
for (const key of Object.keys(raw)) {
const known = MODEL_SLOTS as readonly string[];
if (!known.includes(key)) {
return { ok: false, error: `unknown slot "${key}" (supported: ${known.join(", ")})` };
}
const parsed = parseRoleOverride(raw[key], key);
if (!parsed.ok) return { ok: false, error: parsed.error };
if (Object.keys(parsed.override).length) {
settings[key as (typeof MODEL_SLOTS)[number]] = parsed.override;
} else {
return { ok: false, error: `slot "${key}" must set model or thinkingLevel (absent slot = unset)` };
}
}
return { ok: true, settings };
}
/** The section→parser map for PUT /api/settings/[section] (the route also /** The section→parser map for PUT /api/settings/[section] (the route also
* carries the special prompts sub-resource). */ * carries the special prompts sub-resource). */
export const SETTINGS_SECTIONS = { export const SETTINGS_SECTIONS = {
@@ -300,6 +326,7 @@ export const SETTINGS_SECTIONS = {
agent: parseAgentSettingsBody, agent: parseAgentSettingsBody,
appearance: parseAppearanceSettingsBody, appearance: parseAppearanceSettingsBody,
graph: parseGraphSettingsBody, graph: parseGraphSettingsBody,
models: parseModelsSettingsBody,
} as const; } as const;
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS; export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
+13 -8
View File
@@ -22,20 +22,25 @@ export const stores = {
export type Stores = typeof stores; export type Stores = typeof stores;
/** config.ts's MODELS narrowed to the background roles — the compiled /** config.ts's MODELS as the compiled factory layer the resolution reads —
* factory layer the resolution reads. core stays pure (it takes this as a * ALL NINE roles since the model-slots wave (2026-09-20): the strong
* parameter, never imports config at runtime); the prober/teacher pins * slot's factory fallback IS the prober pin. core stays pure (it takes
* are chat-settings-controlled and deliberately absent here. Tests pin * this as a parameter, never imports config at runtime). Per-role
* that the role set matches core's BACKGROUND_MODEL_ROLES. */ * OVERRIDES stay background-only (the conversation pair is
const BACKGROUND_MODEL_PINS: ModelRoleSpecs = { * chat-settings-controlled) — that split lives in core's
* BACKGROUND_MODEL_ROLES, unchanged. */
const MODEL_PINS: ModelRoleSpecs = {
prober: MODELS.prober,
teacher: MODELS.teacher,
researcher: MODELS.researcher, researcher: MODELS.researcher,
demoBuilder: MODELS.demoBuilder, demoBuilder: MODELS.demoBuilder,
visualMaker: MODELS.visualMaker, visualMaker: MODELS.visualMaker,
pdfReader: MODELS.pdfReader,
extractor: MODELS.extractor, extractor: MODELS.extractor,
auditor: MODELS.auditor, auditor: MODELS.auditor,
labeler: MODELS.labeler, labeler: MODELS.labeler,
}; };
export { BACKGROUND_MODEL_PINS }; export { MODEL_PINS };
/** /**
* The resolved view of the settings record (core/settings-resolution.ts): * The resolved view of the settings record (core/settings-resolution.ts):
@@ -44,5 +49,5 @@ export { BACKGROUND_MODEL_PINS };
* no caching layer (stale toggles after a settings PUT would be worse). * no caching layer (stale toggles after a settings PUT would be worse).
*/ */
export function effectiveSettings(): EffectiveSettings { export function effectiveSettings(): EffectiveSettings {
return resolveSettings(settingsStore.readSettings(), BACKGROUND_MODEL_PINS); return resolveSettings(settingsStore.readSettings(), MODEL_PINS);
} }
+1 -1
View File
@@ -16,7 +16,7 @@ import type { CallUsage, ExtractionRunUsage, NamingRunUsage } from "../src/core/
import { PRICES, callCost } from "../src/core/prices.ts"; import { PRICES, callCost } from "../src/core/prices.ts";
/** A model with full published pricing (from the generated registry). */ /** A model with full published pricing (from the generated registry). */
const PRICED = "z-ai/glm-5.3-flash"; const PRICED = "openrouter:z-ai/glm-5.3-flash";
assert.ok(PRICES[PRICED], "fixture model must exist in PRICES"); assert.ok(PRICES[PRICED], "fixture model must exist in PRICES");
const step = (input: number, output: number): CallUsage => ({ const step = (input: number, output: number): CallUsage => ({
+2 -1
View File
@@ -553,7 +553,8 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const run = record.extractions![0]!; const run = record.extractions![0]!;
assert.equal(run.stats?.nodes, 0); // linked, not created assert.equal(run.stats?.nodes, 0); // linked, not created
assert.equal(run.stats?.events, 3); assert.equal(run.stats?.events, 3);
assert.equal(run.stages.extractor.model, "faux-1"); // the scripted engine's model id // the scripted engine's model, provider-qualified (model slots wave)
assert.equal(run.stages.extractor.model, "faux:faux-1");
assert.equal(run.stages.extractor.steps.length, 1); // the submit call — the run STOPS after a clean submit assert.equal(run.stages.extractor.steps.length, 1); // the submit call — the run STOPS after a clean submit
const step = run.stages.extractor.steps[0]!; const step = run.stages.extractor.steps[0]!;
assert.ok(step.inputTokens! > 0 && step.outputTokens! > 0, "estimated usage captured"); assert.ok(step.inputTokens! > 0 && step.outputTokens! > 0, "estimated usage captured");
+3 -1
View File
@@ -1218,7 +1218,9 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
assert.equal(job.status, "error"); assert.equal(job.status, "error");
assert.equal(job.error, "Aborted by learner"); assert.equal(job.error, "Aborted by learner");
assert.equal(job.usage!.steps, 1, "usage-so-far persisted by the runner's finally"); assert.equal(job.usage!.steps, 1, "usage-so-far persisted by the runner's finally");
assert.equal(job.usage!.model, "z-ai/glm-5.3-flash", "the config role's OpenRouter id"); // model slots wave: the stamp is the ENGINE's actual model,
// provider-qualified — the scripted faux engine here.
assert.equal(job.usage!.model, "faux:faux-1");
// faux estimates tokens from the prompt/response text — the // faux estimates tokens from the prompt/response text — the
// pinned fact is that the ONE completed step's spend survived. // pinned fact is that the ONE completed step's spend survived.
assert.ok(job.usage!.inputTokens! > 0, "input tokens billed"); assert.ok(job.usage!.inputTokens! > 0, "input tokens billed");
+87 -12
View File
@@ -1,21 +1,27 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { modelCatalog, validateChatSettings, catalogDefaultsFor, toModelDto, rolePinsFor, catalogRolePins } from "../src/server/model-catalog.ts"; 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 { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { MODELS } from "../config.ts"; import { MODELS } from "../config.ts";
import { piEngineFor } from "../src/agent/models.ts"; import { piEngineFor } from "../src/agent/models.ts";
import { BACKGROUND_MODEL_ROLES, type BackgroundModelRole } from "../src/core/settings.ts"; import { BACKGROUND_MODEL_ROLES, type BackgroundModelRole } from "../src/core/settings.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts"; import { resolveSettings } from "../src/core/settings-resolution.ts";
import { BACKGROUND_MODEL_PINS } from "../src/server/stores.ts"; import { MODEL_PINS } from "../src/server/stores.ts";
// The model-control wave (2026-09-12): pi-ai owns the registry — these pins // The model-control wave (2026-09-12): pi-ai owns the registry — these pins
// hold our projection + validation honest against the shipped catalog, and // 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 // pin pi's per-model thinking truth so a pi-ai upgrade can't silently change
// what we send. // 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", () => { describe("the model catalog projection", () => {
it("serves pi-ai's OpenRouter catalog with the picker's DTO shape", () => { it("serves the keyed providers' catalogs with the picker's DTO shape", () => {
const { models, defaults } = modelCatalog(); const { models, defaults, providers, slots } = modelCatalog();
// 0.85.1 ships 366 OpenRouter models — the exact count is pi's, but // 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. // the shape and the config models' presence are ours to pin.
assert.ok(models.length > 300, `expected a full catalog, got ${models.length}`); assert.ok(models.length > 300, `expected a full catalog, got ${models.length}`);
@@ -32,23 +38,35 @@ describe("the model catalog projection", () => {
assert.deepEqual(glm!.input, ["text", "image"]); assert.deepEqual(glm!.input, ["text", "image"]);
// pi's current glm price (our old table said 0.15/0.5 — stale) // 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 }); 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", () => { it("defaults mirror the conversation agents' config pin (strong rep = prober pin)", () => {
const d = catalogDefaultsFor(); 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.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"); 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)", () => { it("a picked default in the record's chat section overrides the pin (catalogDefaultsFor, settings wave)", () => {
const d = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" }); 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" }); assert.deepEqual(d, { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" });
// partial: only the model chosen → thinking falls back to the pin // partial: only the model chosen → thinking falls back to the pin
const partial = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }); const partial = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }, strong);
assert.equal(partial.thinkingLevel, MODELS.prober.effort); assert.equal(partial.thinkingLevel, MODELS.prober.effort);
assert.equal(partial.model.id, "qwen/qwen3.8-flash"); assert.equal(partial.model.id, "qwen/qwen3.8-flash");
// graphContextTokens-only record: model untouched // graphContextTokens-only record: model untouched
assert.deepEqual(catalogDefaultsFor({ graphContextTokens: 512 }), { assert.deepEqual(catalogDefaultsFor({ graphContextTokens: 512 }, strong), {
model: { provider: "openrouter", id: MODELS.prober.model }, model: { provider: "openrouter", id: MODELS.prober.model },
thinkingLevel: MODELS.prober.effort, thinkingLevel: MODELS.prober.effort,
}); });
@@ -67,7 +85,7 @@ describe("the model catalog projection", () => {
describe("the background roles' resolved pins (rolePins)", () => { describe("the background roles' resolved pins (rolePins)", () => {
it("no overrides → every role pins its config MODELS spec, nested DTO shape, all six roles", () => { it("no overrides → every role pins its config MODELS spec, nested DTO shape, all six roles", () => {
const pins = rolePinsFor(resolveSettings(null, BACKGROUND_MODEL_PINS).agent.models); const pins = rolePinsFor(resolveSettings(null, MODEL_PINS).agent.models);
assert.deepEqual(Object.keys(pins), [...BACKGROUND_MODEL_ROLES]); assert.deepEqual(Object.keys(pins), [...BACKGROUND_MODEL_ROLES]);
for (const role of BACKGROUND_MODEL_ROLES) { for (const role of BACKGROUND_MODEL_ROLES) {
assert.deepEqual(pins[role].model, { provider: "openrouter", id: MODELS[role].model }); assert.deepEqual(pins[role].model, { provider: "openrouter", id: MODELS[role].model });
@@ -82,7 +100,7 @@ describe("the background roles' resolved pins (rolePins)", () => {
version: 1, version: 1,
agent: { models: { researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } } } }, agent: { models: { researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } } } },
}; };
const pins = rolePinsFor(resolveSettings(record, BACKGROUND_MODEL_PINS).agent.models); const pins = rolePinsFor(resolveSettings(record, MODEL_PINS).agent.models);
assert.equal(pins.researcher.model.id, "qwen/qwen3.8-flash"); 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.researcher.thinkingLevel, MODELS.researcher.effort, "level stays the pin unless overridden too");
assert.equal(pins.visualMaker.model.id, MODELS.visualMaker.model); assert.equal(pins.visualMaker.model.id, MODELS.visualMaker.model);
@@ -91,12 +109,69 @@ describe("the background roles' resolved pins (rolePins)", () => {
it("a thinkingLevel-only override keeps the model, changes the level", () => { it("a thinkingLevel-only override keeps the model, changes the level", () => {
const record = { version: 1, agent: { models: { extractor: { thinkingLevel: "high" as const } } } }; const record = { version: 1, agent: { models: { extractor: { thinkingLevel: "high" as const } } } };
const pins = rolePinsFor(resolveSettings(record, BACKGROUND_MODEL_PINS).agent.models); const pins = rolePinsFor(resolveSettings(record, MODEL_PINS).agent.models);
assert.equal(pins.extractor.model.id, MODELS.extractor.model); assert.equal(pins.extractor.model.id, MODELS.extractor.model);
assert.equal(pins.extractor.thinkingLevel, "high"); 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", () => { describe("the settings validation gate", () => {
it("accepts a supported level and rejects an unsupported one with the list", () => { 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" }); const ok = validateChatSettings({ model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "max" });
+3 -3
View File
@@ -48,7 +48,7 @@ describe("activity dock pure layer", () => {
it("folds cost with jobCost; absent usage is null, never zero", () => { it("folds cost with jobCost; absent usage is null, never zero", () => {
const priced = job({ const priced = job({
usage: { usage: {
model: "z-ai/glm-5.3-flash", model: "openrouter:z-ai/glm-5.3-flash",
inputTokens: 10_000, inputTokens: 10_000,
outputTokens: 1000, outputTokens: 1000,
cacheReadTokens: 5000, cacheReadTokens: 5000,
@@ -67,7 +67,7 @@ describe("activity dock pure layer", () => {
it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => { it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => {
const base = { const base = {
model: "z-ai/glm-5.3-flash", model: "openrouter:z-ai/glm-5.3-flash",
inputTokens: 400_000, // running total (much bigger — must NOT feed the line) inputTokens: 400_000, // running total (much bigger — must NOT feed the line)
outputTokens: 8_000, outputTokens: 8_000,
cacheReadTokens: 0, cacheReadTokens: 0,
@@ -166,7 +166,7 @@ describe("activity section render", () => {
it("shows total running cost when jobs are priced", () => { it("shows total running cost when jobs are priced", () => {
const usage = { const usage = {
model: "z-ai/glm-5.3-flash", model: "openrouter:z-ai/glm-5.3-flash",
inputTokens: 10_000, inputTokens: 10_000,
outputTokens: 1000, outputTokens: 1000,
cacheReadTokens: 0, cacheReadTokens: 0,
+1 -1
View File
@@ -18,7 +18,7 @@ function turn(over: Partial<TurnUsage> = {}): TurnUsage {
id: "t1", id: "t1",
messageId: "m1", messageId: "m1",
at: "2026-09-09T12:00:00Z", at: "2026-09-09T12:00:00Z",
model: "z-ai/glm-5.3-flash", model: "openrouter:z-ai/glm-5.3-flash",
steps: [step(10_000, 1_000)], steps: [step(10_000, 1_000)],
...over, ...over,
}; };
+43 -2
View File
@@ -11,6 +11,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
parseAgentSettingsBody, parseAgentSettingsBody,
parseModelsSettingsBody,
parseAppearanceSettingsBody, parseAppearanceSettingsBody,
parseGeneralSettingsBody, parseGeneralSettingsBody,
parseGraphSettingsBody, parseGraphSettingsBody,
@@ -158,7 +159,47 @@ describe("parseGraphSettingsBody", () => {
}); });
describe("SETTINGS_SECTIONS (the route's section map)", () => { describe("SETTINGS_SECTIONS (the route's section map)", () => {
it("maps exactly the five record sections", () => { it("maps exactly the six record sections", () => {
assert.deepEqual(Object.keys(SETTINGS_SECTIONS), ["general", "chat", "agent", "appearance", "graph"]); assert.deepEqual(Object.keys(SETTINGS_SECTIONS), ["general", "chat", "agent", "appearance", "graph", "models"]);
});
});
describe("parseModelsSettingsBody (model slots wave, 2026-09-20)", () => {
it("parses the three slot tiers, each field independent", () => {
const r = parseModelsSettingsBody({
strong: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } },
cheap: { thinkingLevel: "high" },
vision: { model: { provider: "zai", id: "glm-5.3-flash" }, thinkingLevel: "low" },
});
assert.ok(r.ok);
assert.deepEqual(r.settings, {
strong: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } },
cheap: { thinkingLevel: "high" },
vision: { model: { provider: "zai", id: "glm-5.3-flash" }, thinkingLevel: "low" },
});
});
it("{} is the legal clear-slots write", () => {
const r = parseModelsSettingsBody({});
assert.ok(r.ok);
assert.deepEqual(r.settings, {});
});
it("unknown slots are 400s with the supported list", () => {
const bad = parseModelsSettingsBody({ mega: { thinkingLevel: "high" } });
assert.equal(bad.ok, false);
if (!bad.ok) assert.match(bad.error, /unknown slot "mega" \(supported: strong, cheap, vision\)/);
});
it("a present-but-empty slot object is rejected (absent slot = unset)", () => {
const bad = parseModelsSettingsBody({ strong: {} });
assert.equal(bad.ok, false);
if (!bad.ok) assert.match(bad.error, /slot "strong" must set model or thinkingLevel/);
});
it("malformed slot shapes are 400s", () => {
assert.equal(parseModelsSettingsBody({ cheap: { model: { provider: "x" } } }).ok, false);
assert.equal(parseModelsSettingsBody({ vision: { thinkingLevel: 7 } }).ok, false);
assert.equal(parseModelsSettingsBody("nope").ok, false);
}); });
}); });
+125 -18
View File
@@ -1,26 +1,42 @@
/** /**
* Tests for the resolution layer (settings-storage wave, 2026-09-19): * Tests for the resolution layer (settings-storage wave, 2026-09-19;
* core/settings-resolution.ts — the delta→concrete projection. The factory * model-slots wave 2026-09-20 adds the slot tier): core/settings-resolution.ts
* layer is threaded in as a parameter (core stays pure; server/stores.ts * — the delta→concrete projection. The factory layer is threaded in as a
* threads config.ts's MODELS), so these tests use a small synthetic pin. * parameter (core stays pure; server/stores.ts threads config.ts's MODELS),
* so these tests use a small synthetic pin spanning ALL NINE roles (the
* factory contract since the slots wave — the strong slot's rep IS the
* prober pin).
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
extractionEnabled, extractionEnabled,
factorySlotRep,
graphContextEnabled, graphContextEnabled,
resolveModelSpec, resolveModelSpec,
resolveSettings, resolveSettings,
resolveSlot,
type ModelRoleSpecs,
} from "../src/core/settings-resolution.ts"; } from "../src/core/settings-resolution.ts";
import { BACKGROUND_MODEL_ROLES, type UserSettings } from "../src/core/settings.ts"; import { BACKGROUND_MODEL_ROLES, type ModelSlotName, type UserSettings } from "../src/core/settings.ts";
const FACTORY_PINS = Object.fromEntries( const FACTORY_PINS = Object.fromEntries(
BACKGROUND_MODEL_ROLES.map((role, i) => [ [
"prober",
"teacher",
"researcher",
"demoBuilder",
"visualMaker",
"pdfReader",
"extractor",
"auditor",
"labeler",
].map((role, i) => [
role, role,
{ model: `factory/model-${i}`, effort: i % 2 === 0 ? "low" : "off" }, { provider: i === 5 ? "other" : "openrouter", model: `factory/model-${i}`, effort: i % 2 === 0 ? "low" : "off" },
]), ]),
) as Record<(typeof BACKGROUND_MODEL_ROLES)[number], { model: string; effort: "low" | "off" }>; ) as ModelRoleSpecs;
describe("resolveSettings", () => { describe("resolveSettings", () => {
it("null record (first boot): every knob lands on its factory default", () => { it("null record (first boot): every knob lands on its factory default", () => {
@@ -29,11 +45,28 @@ describe("resolveSettings", () => {
assert.deepEqual(eff.chat, {}); // BYOK: no default model, ever assert.deepEqual(eff.chat, {}); // BYOK: no default model, ever
assert.deepEqual(eff.agent.tools, {}); assert.deepEqual(eff.agent.tools, {});
assert.deepEqual(eff.agent.prompts, {}); assert.deepEqual(eff.agent.prompts, {});
assert.deepEqual(eff.agent.search, {});
for (const role of BACKGROUND_MODEL_ROLES) { for (const role of BACKGROUND_MODEL_ROLES) {
assert.equal(eff.agent.models[role].id, FACTORY_PINS[role].model); assert.equal(eff.agent.models[role].id, FACTORY_PINS[role]!.model);
assert.equal(eff.agent.models[role].provider, "openrouter"); assert.equal(eff.agent.models[role].provider, FACTORY_PINS[role]!.provider);
assert.equal(eff.agent.models[role].thinkingLevel, FACTORY_PINS[role].effort); assert.equal(eff.agent.models[role].thinkingLevel, FACTORY_PINS[role]!.effort);
} }
// slots with no record: every tier lands on its factory rep pin
assert.deepEqual(eff.models.strong, {
provider: "openrouter",
id: FACTORY_PINS.prober!.model,
thinkingLevel: FACTORY_PINS.prober!.effort,
});
assert.deepEqual(eff.models.cheap, {
provider: "openrouter",
id: FACTORY_PINS.researcher!.model,
thinkingLevel: FACTORY_PINS.researcher!.effort,
});
assert.deepEqual(eff.models.vision, {
provider: "openrouter",
id: FACTORY_PINS.visualMaker!.model,
thinkingLevel: FACTORY_PINS.visualMaker!.effort,
});
assert.equal(eff.appearance.mode, "dark"); assert.equal(eff.appearance.mode, "dark");
assert.equal(eff.appearance.background, null); assert.equal(eff.appearance.background, null);
assert.equal(eff.appearance.fontFamily, null); assert.equal(eff.appearance.fontFamily, null);
@@ -52,6 +85,7 @@ describe("resolveSettings", () => {
models: { researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } } }, models: { researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } } },
tools: { research: false }, tools: { research: false },
prompts: { "system-prompt.md": true }, prompts: { "system-prompt.md": true },
search: { engine: "tavily" },
}, },
appearance: { mode: "light", proseFontSize: 20, mdRendering: { headingWeight: 600 } }, appearance: { mode: "light", proseFontSize: 20, mdRendering: { headingWeight: 600 } },
graph: { enabled: false }, graph: { enabled: false },
@@ -64,6 +98,7 @@ describe("resolveSettings", () => {
id: "qwen/qwen3.8-flash", id: "qwen/qwen3.8-flash",
thinkingLevel: "low", // thinking NOT overridden → factory effort thinkingLevel: "low", // thinking NOT overridden → factory effort
}); });
assert.deepEqual(eff.agent.search, { engine: "tavily" });
assert.deepEqual(eff.agent.tools, { research: false }); assert.deepEqual(eff.agent.tools, { research: false });
assert.deepEqual(eff.agent.prompts, { "system-prompt.md": true }); assert.deepEqual(eff.agent.prompts, { "system-prompt.md": true });
assert.equal(eff.appearance.mode, "light"); assert.equal(eff.appearance.mode, "light");
@@ -79,29 +114,101 @@ describe("resolveSettings", () => {
{ version: 1, agent: { models: { extractor: { thinkingLevel: "medium" } } } }, { version: 1, agent: { models: { extractor: { thinkingLevel: "medium" } } } },
FACTORY_PINS, FACTORY_PINS,
); );
const extractor = FACTORY_PINS.extractor; const extractor = FACTORY_PINS.extractor!;
assert.deepEqual(eff.agent.models.extractor, { assert.deepEqual(eff.agent.models.extractor, {
provider: "openrouter", provider: extractor.provider,
id: extractor.model, id: extractor.model,
thinkingLevel: "medium", thinkingLevel: "medium",
}); });
// sibling roles untouched // sibling roles untouched
assert.equal(eff.agent.models.researcher.thinkingLevel, FACTORY_PINS.researcher.effort); assert.equal(eff.agent.models.researcher.thinkingLevel, FACTORY_PINS.researcher!.effort);
}); });
it("resolveModelSpec is field-independent per override aspect", () => { it("resolveModelSpec is field-independent per override aspect", () => {
assert.deepEqual( assert.deepEqual(
resolveModelSpec("labeler", { model: { provider: "other", id: "x" } }, FACTORY_PINS), resolveModelSpec("labeler", { model: { provider: "other", id: "x" } }, FACTORY_PINS),
{ provider: "other", id: "x", thinkingLevel: FACTORY_PINS.labeler.effort }, { provider: "other", id: "x", thinkingLevel: FACTORY_PINS.labeler!.effort },
); );
assert.deepEqual(resolveModelSpec("labeler", {}, FACTORY_PINS), { assert.deepEqual(resolveModelSpec("labeler", {}, FACTORY_PINS), {
provider: "openrouter", provider: FACTORY_PINS.labeler!.provider,
id: FACTORY_PINS.labeler.model, id: FACTORY_PINS.labeler!.model,
thinkingLevel: FACTORY_PINS.labeler.effort, thinkingLevel: FACTORY_PINS.labeler!.effort,
}); });
}); });
}); });
describe("the slot tier (model slots wave, 2026-09-20)", () => {
it("slot alone re-tiers every role of its tier (no role overrides present)", () => {
const eff = resolveSettings(
{ version: 1, models: { cheap: { model: { provider: "zai", id: "glm-5.3-flash" } } } },
FACTORY_PINS,
);
for (const role of ["researcher", "demoBuilder", "extractor", "auditor", "labeler"] as const) {
assert.deepEqual(eff.agent.models[role as "researcher"], {
provider: "zai",
id: "glm-5.3-flash",
thinkingLevel: FACTORY_PINS[role]!.effort, // thinking not in slot → factory
}, role);
}
// other tiers untouched
assert.equal(eff.agent.models.visualMaker.id, FACTORY_PINS.visualMaker!.model);
});
it("role override still beats the slot; slot beats the factory pin (the chain)", () => {
const eff = resolveSettings(
{
version: 1,
agent: { models: { researcher: { model: { provider: "groq", id: "r-override" } } } },
models: { cheap: { model: { provider: "zai", id: "r-slot" }, thinkingLevel: "high" } },
},
FACTORY_PINS,
);
// researcher: override model + slot thinking (field-independent)
assert.deepEqual(eff.agent.models.researcher, {
provider: "groq",
id: "r-override",
thinkingLevel: "high",
});
// labeler (same tier, no override): full slot
assert.deepEqual(eff.agent.models.labeler, { provider: "zai", id: "r-slot", thinkingLevel: "high" });
});
it("vision slot covers visualMaker AND pdfReader; strong's rep is the prober pin", () => {
const eff = resolveSettings(
{ version: 1, models: { vision: { thinkingLevel: "max" } } },
FACTORY_PINS,
);
assert.equal(eff.agent.models.visualMaker.thinkingLevel, "max");
assert.equal(eff.agent.models.visualMaker.id, FACTORY_PINS.visualMaker!.model);
// resolved slot views exist for every tier, set or not
assert.deepEqual(eff.models.strong, factorySlotRep("strong", FACTORY_PINS));
});
it("resolveSlot: slot over factory rep, per field", () => {
assert.deepEqual(resolveSlot("strong", { thinkingLevel: "medium" }, FACTORY_PINS), {
provider: FACTORY_PINS.prober!.provider,
id: FACTORY_PINS.prober!.model,
thinkingLevel: "medium",
});
const rep = factorySlotRep("vision", FACTORY_PINS);
assert.equal(rep.id, FACTORY_PINS.visualMaker!.model);
const slot: ModelSlotName = "cheap";
assert.deepEqual(resolveSlot(slot, undefined, FACTORY_PINS), {
provider: FACTORY_PINS.researcher!.provider,
id: FACTORY_PINS.researcher!.model,
thinkingLevel: FACTORY_PINS.researcher!.effort,
});
});
it("absent provider coordinate anywhere rides the factory provider (legacy ids)", () => {
// resolveModelSpec with no slots and a factory provider of "other"
// (pdfReader is index 5 in the synthetic pins): a legacy override
// with no provider keeps the FACTORY provider, not a hardcoded one.
const spec = resolveModelSpec("auditor", {}, FACTORY_PINS);
assert.equal(spec.provider, FACTORY_PINS.auditor!.provider);
});
});
describe("the graph channel gates (the wave's seam honoring)", () => { describe("the graph channel gates (the wave's seam honoring)", () => {
it("WRITE channel (extraction): dies with the master switch only", () => { it("WRITE channel (extraction): dies with the master switch only", () => {
assert.equal(extractionEnabled({ enabled: true, embedding: true, clustering: true, labeling: true }), true); assert.equal(extractionEnabled({ enabled: true, embedding: true, clustering: true, labeling: true }), true);
+4
View File
@@ -63,6 +63,10 @@ describe("sanitizeUserSettings", () => {
prompts: { "system-prompt.md": true, "teacher/20-teaching.md": false }, prompts: { "system-prompt.md": true, "teacher/20-teaching.md": false },
search: { engine: "brave" }, search: { engine: "brave" },
}, },
models: {
strong: { model: { provider: "zai", id: "glm-5.3-flash" }, thinkingLevel: "low" },
cheap: { thinkingLevel: "medium" },
},
appearance: { appearance: {
mode: "light", mode: "light",
background: "#ffffff", background: "#ffffff",
+13 -13
View File
@@ -40,7 +40,7 @@ test("sumSteps folds steps; absent stays absent until a value appears", () => {
}); });
test("callCost: glm-5.3-flash math — fresh input, cached read, output", () => { test("callCost: glm-5.3-flash math — fresh input, cached read, output", () => {
const cost = callCost(call({ inputTokens: 100_000, outputTokens: 20_000, cacheReadTokens: 10_000 }), "z-ai/glm-5.3-flash"); const cost = callCost(call({ inputTokens: 100_000, outputTokens: 20_000, cacheReadTokens: 10_000 }), "openrouter:z-ai/glm-5.3-flash");
assert.ok(cost !== null); assert.ok(cost !== null);
// fresh 90k @ $0.075/M + cached 10k @ $0.015/M + output 20k @ $0.25/M // fresh 90k @ $0.075/M + cached 10k @ $0.015/M + output 20k @ $0.25/M
// (pi-ai catalog prices — regenerated tables, scripts/sync-pricing) // (pi-ai catalog prices — regenerated tables, scripts/sync-pricing)
@@ -50,7 +50,7 @@ test("callCost: glm-5.3-flash math — fresh input, cached read, output", () =>
test("callCost: cache writes bill at the cacheWrite rate", () => { test("callCost: cache writes bill at the cacheWrite rate", () => {
const cost = callCost( const cost = callCost(
call({ inputTokens: 1_000_000, outputTokens: 0, cacheWriteTokens: 500_000 }), call({ inputTokens: 1_000_000, outputTokens: 0, cacheWriteTokens: 500_000 }),
"qwen/qwen3.8-max-0902", "openrouter:qwen/qwen3.8-max-0902",
); );
assert.ok(cost !== null); assert.ok(cost !== null);
// fresh 1M @ $2/M + cache write 500k @ $2.5/M // fresh 1M @ $2/M + cache write 500k @ $2.5/M
@@ -59,7 +59,7 @@ test("callCost: cache writes bill at the cacheWrite rate", () => {
test("callCost: unknown model and absent fields are null, never zero", () => { test("callCost: unknown model and absent fields are null, never zero", () => {
assert.equal(callCost(call({ inputTokens: 100, outputTokens: 5 }), "not/a-model"), null); assert.equal(callCost(call({ inputTokens: 100, outputTokens: 5 }), "not/a-model"), null);
assert.equal(callCost(call({ inputTokens: 100, outputTokens: undefined }), "z-ai/glm-5.3-flash"), null); assert.equal(callCost(call({ inputTokens: 100, outputTokens: undefined }), "openrouter:z-ai/glm-5.3-flash"), null);
}); });
test("turnCost sums steps; sessionCost reports unpriced turns", () => { test("turnCost sums steps; sessionCost reports unpriced turns", () => {
@@ -67,7 +67,7 @@ test("turnCost sums steps; sessionCost reports unpriced turns", () => {
id: "t1", id: "t1",
messageId: "m1", messageId: "m1",
at: "2026-09-09T00:00:00Z", at: "2026-09-09T00:00:00Z",
model: "z-ai/glm-5.3-flash", model: "openrouter:z-ai/glm-5.3-flash",
steps: [call({ inputTokens: 10_000, outputTokens: 2_000 }), call({ inputTokens: 12_000, outputTokens: 1_000 })], steps: [call({ inputTokens: 10_000, outputTokens: 2_000 }), call({ inputTokens: 12_000, outputTokens: 1_000 })],
}; };
assert.ok(turnCost(turn) !== null); assert.ok(turnCost(turn) !== null);
@@ -81,12 +81,12 @@ test("turnCost sums steps; sessionCost reports unpriced turns", () => {
test("contextWindow: last call's footprint against the model's limit", () => { test("contextWindow: last call's footprint against the model's limit", () => {
const cw = contextWindow({ const cw = contextWindow({
turns: [ turns: [
{ id: "t1", messageId: "m1", at: "x", model: "z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] }, { id: "t1", messageId: "m1", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 9_000, outputTokens: 1_000 })] },
{ id: "t2", messageId: "m2", at: "x", model: "z-ai/glm-5.3-flash", steps: [call({ inputTokens: 90_000, outputTokens: 10_000 })] }, { id: "t2", messageId: "m2", at: "x", model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 90_000, outputTokens: 10_000 })] },
], ],
}); });
assert.ok(cw); assert.ok(cw);
assert.equal(cw.model, "z-ai/glm-5.3-flash"); assert.equal(cw.model, "openrouter:z-ai/glm-5.3-flash");
assert.equal(cw.usedTokens, 100_000); assert.equal(cw.usedTokens, 100_000);
assert.equal(cw.limit, 1_048_576); assert.equal(cw.limit, 1_048_576);
assert.equal(contextWindow({ turns: [] }), null); assert.equal(contextWindow({ turns: [] }), null);
@@ -96,7 +96,7 @@ test("jobCost: null without usage, computed with it", () => {
assert.equal(jobCost({}), null); assert.equal(jobCost({}), null);
const job: Pick<JobRecord, "usage"> = { const job: Pick<JobRecord, "usage"> = {
usage: { usage: {
model: "z-ai/glm-5.3-flash", model: "openrouter:z-ai/glm-5.3-flash",
inputTokens: 1_000_000, inputTokens: 1_000_000,
outputTokens: 100_000, outputTokens: 100_000,
cacheReadTokens: 0, cacheReadTokens: 0,
@@ -117,7 +117,7 @@ test("appendTurnUsage: appends, idempotent per turn id, continuation turns (same
id: "turn_1", id: "turn_1",
messageId: "msg_a", messageId: "msg_a",
at: "2026-09-09T00:00:00Z", at: "2026-09-09T00:00:00Z",
model: "z-ai/glm-5.3-flash", model: "openrouter:z-ai/glm-5.3-flash",
steps: [call({ inputTokens: 100, outputTokens: 10 })], steps: [call({ inputTokens: 100, outputTokens: 10 })],
}; };
chatStore.appendTurnUsage(record.id, entry); chatStore.appendTurnUsage(record.id, entry);
@@ -149,8 +149,8 @@ test("appendExtractionUsage: appends, idempotent per startedAt, not in list payl
durationMs: 42_000, durationMs: 42_000,
stats: { nodes: 1, edges: 2, events: 3 }, stats: { nodes: 1, edges: 2, events: 3 },
stages: { stages: {
extractor: { model: "z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] }, extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
auditor: { model: "z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] }, auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
}, },
}); });
chatStore.appendExtractionUsage(record.id, runEntry(1)); chatStore.appendExtractionUsage(record.id, runEntry(1));
@@ -186,8 +186,8 @@ test("appendSourceUsage: appends to the source record, idempotent per startedAt,
durationMs: 42_000, durationMs: 42_000,
stats: { nodes: 1, edges: 2, events: 3 }, stats: { nodes: 1, edges: 2, events: 3 },
stages: { stages: {
extractor: { model: "z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] }, extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] },
auditor: { model: "z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] }, auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },
}, },
}); });
const id = "src_usagetest01"; const id = "src_usagetest01";