feat: model + thinking control — the stats bar's picker, on pi-ai's catalog

The model-control wave (user-decided design, 2026-09-12): ONE controller
for the conversation agents (prober/teacher are functionally one agent);
background sub-agents stay config-pinned. pi-ai owns the registry — the
shipped 366-model OpenRouter catalog (cost, context, modality,
thinkingLevelMap), getSupportedThinkingLevels/clampThinkingLevel for the
per-model thinking truth, and streamSimple's automatic clamp. No second
registry to drift out of sync.

Seams:
- config.ts: ReasoningEffort retired for pi's ThinkingLevel
  (off|minimal|low|medium|high|xhigh|max). GLM roles go honest at "low" —
  pi's catalog says GLM-5.3-flash accepts exactly low|high|max, and the old
  "minimal" was silently clamped to low by streamSimple since the swap;
  nothing changes behaviorally. visualMaker → qwen/qwen3.8-flash (fixes
  the dead qwen/qwen3.8-max slug — the catalog only ships -0902; also the
  user's cost pick, 0.15/0.47 per-1M).
- src/agent/models.ts: piEngineFor(role, settings?) — provider+id resolved
  from the catalog, thinking level from settings ?? config, CLAMPED to the
  model's supported list (a settings change between models can never send
  an invalid level). PiEngine gains `level` (observability/tests).
- ChatRecord.settings {model?: {provider, id}, thinkingLevel?} — written
  by PUT /api/chats/[id]/settings (replace-whole, no updatedAt churn),
  read by chat-handler at turn composition: agents are stateless per turn,
  so the change is next-turn-effective by construction. The turns log
  still records the ACTUAL billed model (the honest witness).
- GET /api/models: the configured-provider catalog as client-safe DTOs
  (core/contracts/models.ts) + config defaults; a second provider joins
  the picker by registering it in piModels() — nothing else changes.
- PUT settings validation gate (src/server/model-catalog.ts): unknown
  provider/model or unsupported level → 400 carrying the supported list,
  never a silent clamp.
- scripts/sync-pricing.ts: regenerated PRICES/CONTEXT_LIMITS from pi-ai's
  shipped openrouter.json (offline; same source as the engine + picker).
  All 366 models priced; glm's stale 0.15/0.5 → pi's 0.075/0.25, limit
  1310720 → 1048576 — cost is inferred at read time, so history re-prices.
- UI (components/model-picker.tsx): stats-bar right side becomes model +
  thinking buttons; menus open ABOVE; fuzzy search over "provider id
  name" (core/fuzzy.ts — pi's /model pattern); model rows show name/id/
  price/context; the thinking menu lists exactly the chosen model's
  supported levels (GLM: low|high|max — no dead options, no "off").
  Model switch drops an incompatible pinned level (falls back to config,
  which clamps honestly) rather than carrying a 400.

Tests (350/350, +15): catalog projection + defaults, the settings gate,
pi thinking truth pinned against upgrades (glm low|high|max; minimal→low;
off→low — GLM cannot disable thinking), the engine factory's
override/fallback/clamp/throw matrix, fuzzy ranking, store round-trip,
and the re-priced folds (usage, stats bar, activity dock).

Live smoke (disposable driver, deleted): catalog route 366 + defaults;
glm+minimal → 400 + supported; turn 1 billed qwen/qwen3.8-flash under
the override; mid-session switch flipped turn 2 to glm; clear-overrides
restored the config pin. ALL CHECKS PASS on real OpenRouter.
This commit is contained in:
amosblomqvist
2026-09-12 06:02:40 +03:00
parent 740293c361
commit de0c286311
26 changed files with 1649 additions and 131 deletions
+52
View File
@@ -0,0 +1,52 @@
import { getChat, validateChatSettings, stores } from "@/src/server";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { ChatSettings } from "@/src/core";
/**
* The per-chat model control (model-control wave): replace-whole settings —
* the picker sends the complete desired object ({} clears all overrides and
* returns the chat to config.ts's pin). Validation is the catalog gate
* (src/server/model-catalog.ts): unknown provider/model or an unsupported
* thinking level is a 400 carrying the supported list, never a silent clamp
* — the user must see what the model actually accepts. Effective next turn
* by construction (agents are stateless per turn).
*/
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!getChat(id)) return Response.json({ error: "Not found" }, { status: 404 });
const body = (await req.json().catch(() => null)) as
| { model?: { provider?: unknown; id?: unknown }; thinkingLevel?: unknown }
| null;
if (!body || typeof body !== "object") {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const settings: ChatSettings = {};
if (body.model !== undefined) {
if (
typeof body.model !== "object" ||
body.model === null ||
typeof body.model.provider !== "string" ||
typeof body.model.id !== "string"
) {
return Response.json({ error: "model must be {provider, id}" }, { status: 400 });
}
settings.model = { provider: body.model.provider, id: body.model.id };
}
if (body.thinkingLevel !== undefined) {
if (typeof body.thinkingLevel !== "string") {
return Response.json({ error: "thinkingLevel must be a string" }, { status: 400 });
}
settings.thinkingLevel = body.thinkingLevel as ThinkingLevel;
}
const verdict = validateChatSettings(settings);
if (!verdict.ok) {
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
}
// Replace-whole persists the validated object verbatim — no merges, so a
// stale client can never resurrect a cleared override.
stores.chatStore.saveSettings(id, settings);
return Response.json({ settings });
}
+11
View File
@@ -0,0 +1,11 @@
import { modelCatalog } from "@/src/server";
/**
* The model picker's data (model-control wave): pi-ai's configured-provider
* catalog as client-safe DTOs — id/name/cost/context/modality plus each
* model's SUPPORTED thinking levels (per its thinkingLevelMap). Static
* catalog: no network, stable within a pi-ai version.
*/
export async function GET() {
return Response.json(modelCatalog());
}
+1
View File
@@ -25,6 +25,7 @@ export default async function ChatPage({
// A teacher turn that outlived its client: the remounted view must
// attach (busy shimmer + poll), never resurrect the stale record.
initiallyInFlight={isTurnRunning(chat.id)}
initialSettings={chat.settings}
/>
);
}
+23 -1
View File
@@ -21,6 +21,7 @@ import { SubagentSteerCard } from "@/components/artifacts/steer-card";
import { Composer } from "@/components/composer";
import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar";
import type { ChatSettings } from "@/src/core";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanWindow } from "@/components/plan-window";
import type { PlanBlockData } from "@/src/core";
@@ -85,6 +86,7 @@ export function Chat({
initialMessages,
initialPrompt,
initiallyInFlight,
initialSettings,
}: {
chatId: string;
title: string;
@@ -93,6 +95,8 @@ export function Chat({
/** A teacher turn is running server-side but attached to no client
* (navigated away mid-turn, returned while in flight). */
initiallyInFlight?: boolean;
/** The record's per-chat model/thinking settings (the picker's seed). */
initialSettings?: ChatSettings;
}) {
const { messages, sendMessage, addToolOutput, setMessages, status, error, stop } =
useChat<TeacherUIMessage>({
@@ -250,6 +254,9 @@ export function Chat({
// server-side), and kept fresh by syncFromServer whenever it already
// fetches the record. No turns yet → the bar renders "—" everywhere.
const [turns, setTurns] = useState<TurnUsage[]>([]);
// The per-chat model control state (model-picker writes via PUT; the
// record is the source of truth, lifted here so the stats bar re-renders).
const [settings, setSettings] = useState<ChatSettings | undefined>(initialSettings);
const syncTurns = useCallback(async () => {
try {
const res = await fetch(`/api/chats/${chatId}`);
@@ -582,6 +589,9 @@ export function Chat({
<DockOverlay
pending={pending}
chatId={chatId}
settings={settings}
onSettingsSaved={setSettings}
requiresReveal={requiresReveal}
phase={phases.at(-1) ?? "probe"}
turns={turns}
@@ -645,6 +655,9 @@ function DockOverlay({
turns,
composer,
onSubmitAnswer,
chatId,
settings,
onSettingsSaved,
}: {
pending: ReturnType<typeof findPendingToolCall>;
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
@@ -655,6 +668,10 @@ function DockOverlay({
turns: TurnUsage[];
composer: React.ReactNode;
onSubmitAnswer: (output: QuestionAnswerData) => void;
/** The model control's pass-through (the stats bar hosts it). */
chatId: string;
settings?: ChatSettings;
onSettingsSaved: (settings: ChatSettings) => void;
}) {
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
// Teach-phase mount (session reload with a pending check): the dock starts
@@ -818,7 +835,12 @@ function DockOverlay({
{/* The stats row: beneath the composer/question shell, not
flush to the screen bottom — static height, outside the
morphing shell so it never joins a height animation. */}
<StatsBar turns={turns} />
<StatsBar
turns={turns}
chatId={chatId}
settings={settings}
onSettingsSaved={onSettingsSaved}
/>
</div>
</div>
</div>
+274
View File
@@ -0,0 +1,274 @@
"use client";
/**
* The model + thinking control (model-control wave, 2026-09-12) — the stats
* bar's right side. ONE controller for the conversation agents (prober +
* teacher are functionally one agent); background sub-agents stay
* config-pinned. Writes go to PUT /api/chats/[id]/settings (validated
* server-side against pi-ai's catalog) and take effect on the next turn by
* construction — the agents are stateless per turn.
*
* The data is pi-ai's catalog (GET /api/models): the model menu is a fuzzy
* search over "provider id name" (core/fuzzy.ts — pi's /model pattern), and
* the thinking menu lists EXACTLY the chosen model's supported levels
* (getSupportedThinkingLevels — GLM-5.3-flash shows low|high|max, never a
* level it would silently clamp). Menus open ABOVE the buttons (the
* composer owns everything below).
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
fuzzyScore,
type ChatSettings,
type ModelCatalogEntry,
type ModelCatalogResponse,
} from "@/src/core";
import { formatTokens, prettyModel } from "@/components/usage-format";
/** GET /api/models is fetched once per page load and shared by every
* picker instance (the catalog is static within a pi-ai version). */
let catalogCache: ModelCatalogResponse | undefined;
let catalogInflight: Promise<ModelCatalogResponse> | undefined;
function fetchCatalog(): Promise<ModelCatalogResponse> {
catalogInflight ??= fetch("/api/models")
.then((r) => {
if (!r.ok) throw new Error(`catalog request failed: ${r.status}`);
return r.json() as Promise<ModelCatalogResponse>;
})
.then((c) => {
catalogCache = c;
return c;
})
.catch((err) => {
catalogInflight = undefined; // retry on next mount
throw err;
});
return catalogInflight;
}
function useModelCatalog(): ModelCatalogResponse | undefined {
const [catalog, setCatalog] = useState(catalogCache);
useEffect(() => {
if (catalogCache) return;
let alive = true;
fetchCatalog()
.then((c) => alive && setCatalog(c))
.catch(() => {
/* the picker stays closed on failure; the buttons still display
the effective model from props */
});
return () => {
alive = false;
};
}, []);
return catalog;
}
const LEVEL_LABEL: Record<string, string> = {
off: "Off",
minimal: "Minimal",
low: "Low",
medium: "Medium",
high: "High",
xhigh: "Xhigh",
max: "Max",
};
/** USD per 1M tokens, compact: "$0.08 in · $0.25 out". */
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
return `${fmt(cost.input)}/${fmt(cost.output)}`;
}
export function ModelControl({
chatId,
settings,
onSaved,
fallbackModelId,
}: {
chatId: string;
/** The record's current settings (undefined = config pin). */
settings?: ChatSettings;
/** Lifts the persisted settings into the chat view's state. */
onSaved: (settings: ChatSettings) => void;
/** The last turn's actual model (from the turns log) — the label until
* the catalog (with config's defaults) finishes loading. */
fallbackModelId?: string;
}) {
const catalog = useModelCatalog();
const [open, setOpen] = useState<"model" | "thinking" | null>(null);
const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const defaults = catalog?.defaults;
const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId;
const effectiveLevel =
settings?.thinkingLevel ?? defaults?.thinkingLevel;
const entry = catalog?.models.find((m) => m.id === effectiveId);
const levels = entry?.supportedLevels ?? [];
// Focus the search box when the model menu opens (pi's /model feel).
useEffect(() => {
if (open === "model") searchRef.current?.focus();
}, [open]);
const save = useCallback(
async (next: ChatSettings) => {
setError(null);
try {
const res = await fetch(`/api/chats/${chatId}/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
setError(body.error ?? `settings rejected (${res.status})`);
return;
}
const { settings: saved } = (await res.json()) as { settings: ChatSettings };
onSaved(saved);
setOpen(null);
} catch {
setError("Could not reach the server — try again.");
}
},
[chatId, onSaved],
);
// The filtered, ranked model list.
const results = (catalog?.models ?? [])
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
.filter((r) => r.s !== null)
.sort((a, b) => (b.s! - a.s!) || a.m.id.localeCompare(b.m.id))
.map((r) => r.m);
return (
<div className="relative flex items-center gap-1.5" data-testid="model-control">
<button
type="button"
data-testid="stats-model"
onClick={() => {
setOpen((o) => (o === "model" ? null : "model"));
setQuery("");
setError(null);
}}
aria-expanded={open === "model"}
title="Conversation model — applies next turn"
className="flex items-center gap-1 transition-colors hover:text-foreground"
>
<span>{effectiveId ? prettyModel(effectiveId) : "—"}</span>
</button>
<span aria-hidden>·</span>
<button
type="button"
data-testid="stats-thinking"
onClick={() => {
setOpen((o) => (o === "thinking" ? null : "thinking"));
setError(null);
}}
aria-expanded={open === "thinking"}
title="Thinking level — applies next turn"
className="flex items-center gap-1 transition-colors hover:text-foreground"
>
<span>
{effectiveLevel === undefined ? "Thinking —" : `Thinking: ${LEVEL_LABEL[effectiveLevel] ?? effectiveLevel}`}
</span>
</button>
{open === "model" && (
<div
data-testid="model-menu"
className="absolute bottom-full right-0 z-40 mb-1.5 flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
>
<input
ref={searchRef}
data-testid="model-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search models…"
className="mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
{error && <div className="mb-1.5 text-destructive">{error}</div>}
{!catalog && (
<div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div>
)}
{catalog && results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
)}
<ul data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto">
{results.map((m) => (
<li key={`${m.provider}/${m.id}`}>
<button
type="button"
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
onClick={() => {
// Keep the thinking level only when the new model
// supports it — otherwise drop it (config fallback
// clamps honestly; a carry-over would 400).
const nextLevel =
effectiveLevel !== undefined && m.supportedLevels.includes(effectiveLevel)
? effectiveLevel
: undefined;
void save({ model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel });
}}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
m.id === effectiveId ? "bg-muted/60" : ""
}`}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title="USD per 1M tokens in/out">
{priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
</span>
</button>
</li>
))}
</ul>
<div className="mt-1 border-t px-2 pt-1.5 text-[10px] text-muted-foreground">
Applies to the next turn · background agents stay config-pinned
</div>
</div>
)}
{open === "thinking" && (
<div
data-testid="thinking-menu"
className="absolute bottom-full right-0 z-40 mb-1.5 flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
>
{!catalog && <div className="px-2 py-3 text-center text-muted-foreground">Loading…</div>}
{catalog && levels.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">
{effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first."}
</div>
)}
{levels.map((level) => (
<button
key={level}
type="button"
data-testid={`thinking-option-${level}`}
onClick={() => void save({ ...(settings ?? {}), thinkingLevel: level })}
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
}`}
>
<span>{LEVEL_LABEL[level] ?? level}</span>
{level === effectiveLevel && <span aria-hidden>✓</span>}
</button>
))}
{error && <div className="mt-1 text-destructive">{error}</div>}
<div className="mt-1 border-t px-2 pt-1.5 text-[10px] text-muted-foreground">
Levels this model actually supports
</div>
</div>
)}
</div>
);
}
+27 -14
View File
@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { contextWindow, sessionCost, turnCost, type TurnUsage } from "@/src/core";
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
import { ModelControl } from "@/components/model-picker";
/**
* The below-composer stats bar (wave 4 observability): the session's
@@ -14,11 +15,23 @@ import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format"
* — no cost math lives here. Absent data renders "—", never zero (the
* doctrine): no turns yet → every figure is "—"; turns the price table
* can't price count as unpriced, shown in the popover, never folded into a
* fake $0. The RIGHT side is a PLACEHOLDER for the model + thinking-level
* control (later work, with its contract changes): display-only for now,
* model read from the last turn's usage.
* fake $0. The RIGHT side is the model + thinking control (components/
* model-picker.tsx): one controller for the conversation agents, backed by
* pi-ai's catalog; the buttons show the EFFECTIVE model (the record's
* settings, else config's pin), while the left-side figures still fold the
* turn log's actual billed models.
*/
export function StatsBar({ turns }: { turns: TurnUsage[] }) {
export function StatsBar({
turns,
chatId,
settings,
onSettingsSaved,
}: {
turns: TurnUsage[];
chatId: string;
settings?: ChatSettings;
onSettingsSaved: (settings: ChatSettings) => void;
}) {
const [open, setOpen] = useState(false);
const ctx = contextWindow({ turns });
const { cost, unpriced } = sessionCost({ turns });
@@ -47,15 +60,15 @@ export function StatsBar({ turns }: { turns: TurnUsage[] }) {
<span aria-hidden>·</span>
<span data-testid="stats-cost">{costLabel}</span>
</button>
{/* Model + thinking level — a placeholder for the coming control
(contract changes land with it); display-only today. */}
<div className="flex items-center gap-1.5" data-testid="stats-model-chip">
<span data-testid="stats-model">{lastModel ? prettyModel(lastModel) : "—"}</span>
<span aria-hidden>·</span>
<span data-testid="stats-thinking" title="Thinking level control lands soon">
Thinking —
</span>
</div>
{/* The model + thinking control — one controller for the conversation
agents (prober/teacher); the record's `turns` above still folds
the ACTUAL models billed, regardless of what the picker says. */}
<ModelControl
chatId={chatId}
settings={settings}
onSaved={onSettingsSaved}
fallbackModelId={lastModel}
/>
{open && (
<div
+15 -13
View File
@@ -18,33 +18,35 @@ export const PATHS = {
// THE single place every LLM is chosen and tuned. .env stays for secrets
// (API keys) only — model choice and thinking budget are product tuning,
// not deployment environment, and this section is the one place they grow.
// `effort` is OpenRouter's reasoning scale (docs: reasoning-tokens):
// "none" | "minimal" | "low" | "medium" | "high" | "xhigh".
// "none" disables reasoning tokens entirely.
// `effort` is pi's thinking-level scale (off|minimal|low|medium|high|xhigh
// |max): the per-model truth lives in pi-ai's catalog (thinkingLevelMap) —
// getSupportedThinkingLevels(model) lists what a model actually accepts,
// and streamSimple clamps any other value to the nearest supported one
// (e.g. GLM-5.3-flash supports exactly low|high|max).
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export interface ModelSpec {
/** OpenRouter model id (e.g. "z-ai/glm-5.3-flash"). */
/** OpenRouter model id (e.g. "z-ai/glm-5.3-flash") — must exist in pi-ai's catalog. */
model: string;
/** Thinking budget — what the model may spend on reasoning tokens. */
effort: ReasoningEffort;
effort: ThinkingLevel;
}
export const MODELS = {
/** The prober (agent A) — edge-bracketing questions; keep it lively and adaptive. */
prober: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
prober: { model: "z-ai/glm-5.3-flash", effort: "low" },
/** The teacher (agent B) — planning + streaming, lesson-facing: smart, but keep it lively. */
teacher: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
teacher: { model: "z-ai/glm-5.3-flash", effort: "low" },
/** Background sub-agents: fast and cheap beats deep thought. */
researcher: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
demoBuilder: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
researcher: { model: "z-ai/glm-5.3-flash", effort: "low" },
demoBuilder: { model: "z-ai/glm-5.3-flash", effort: "low" },
/** Must be vision-capable: it looks at rendered SVGs. */
visualMaker: { model: "qwen/qwen3.8-max", effort: "minimal" },
visualMaker: { model: "qwen/qwen3.8-flash", effort: "low" },
/** Extraction is judgment over given evidence — light thinking suffices. */
extractor: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
extractor: { model: "z-ai/glm-5.3-flash", effort: "low" },
/** The auditor verifies evidence exists; keep it cheap. */
auditor: { model: "z-ai/glm-5.3-flash", effort: "minimal" },
auditor: { model: "z-ai/glm-5.3-flash", effort: "low" },
} satisfies Record<string, ModelSpec>;
export type ModelRole = keyof typeof MODELS;
+50 -64
View File
@@ -1,85 +1,72 @@
/**
* Regenerate src/core/prices.ts tables from OpenRouter's published pricing.
* Regenerate src/core/prices.ts tables from pi-ai's shipped OpenRouter
* catalog (providers/data/openrouter.json).
*
* Usage: npx tsx scripts/sync-pricing.ts
*
* Pulls GET https://openrouter.ai/api/v1/models, filters to the models in
* config.ts MODELS (+ known spelling variants), and rewrites the PRICES +
* CONTEXT_LIMITS tables in src/core/prices.ts — the tables are generated;
* hand edits are overwritten. Per-token prices convert to per-1M-token USD;
* fields OpenRouter doesn't publish (null) are omitted. A model missing from
* OpenRouter's list gets no entry — cost renders as absent ("—"), never zero.
* The catalog is pi-ai's curated, versioned snapshot of OpenRouter's model
* list — the SAME source piEngineFor resolves engines from and
* /api/models serves the picker from — so the money channel's lookup table
* can never disagree with the engine. Fully offline (the JSON ships in
* node_modules); refreshes happen by bumping @earendil-works/pi-ai and
* re-running this script. The tables are generated; hand edits are
* overwritten. A model missing from the catalog gets no entry — cost
* renders as absent ("—"), never zero.
*/
import { MODELS } from "../config.ts";
import * as fs from "node:fs";
import * as path from "node:path";
interface OpenRouterModel {
id: string;
context_length?: number;
pricing?: Record<string, string | null>;
}
/** Spelling variants worth keeping priced alongside a config id (the config's
* id must always resolve even if OpenRouter renames the slug). */
const VARIANTS: Record<string, string[]> = {
"qwen/qwen3.8-max": ["qwen/qwen3.8-max-0902"],
"qwen/qwen3.8-max-0902": ["qwen/qwen3.8-max"],
};
const API = "https://openrouter.ai/api/v1/models";
const CATALOG = path.join(
import.meta.dirname,
"..",
"node_modules",
"@earendil-works",
"pi-ai",
"dist",
"providers",
"data",
"openrouter.json",
);
const OUT = path.join(import.meta.dirname, "..", "src", "core", "prices.ts");
const perM = (v: string | null | undefined): number | undefined => {
if (v === null || v === undefined || v === "") return undefined;
const n = Number(v) * 1e6;
return Number.isFinite(n) ? Number(n.toFixed(4)) : undefined;
};
function priceLine(id: string, model: OpenRouterModel): string {
const p = model.pricing ?? {};
const fields = [
`input: ${perM(p.prompt)}`,
`output: ${perM(p.completion)}`,
`cacheRead: ${perM(p.input_cache_read)}`,
...(perM(p.input_cache_write) !== undefined ? [`cacheWrite: ${perM(p.input_cache_write)}`] : []),
];
return `\t${JSON.stringify(id)}: { ${fields.join(", ")} },`;
/** One raw catalog entry (fields we consume; the file carries more). */
interface CatalogModel {
id: string;
name: string;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
}
async function main(): Promise<void> {
const res = await fetch(API);
if (!res.ok) throw new Error(`OpenRouter models request failed: ${res.status} ${res.statusText}`);
const { data } = (await res.json()) as { data: OpenRouterModel[] };
const byId = new Map(data.map((m) => [m.id, m]));
function main(): void {
// Keyed by api ("openai-completions" | "anthropic-messages"), each a
// Record<id, entry> — every OpenRouter id is unique across the two maps.
const catalog = JSON.parse(fs.readFileSync(CATALOG, "utf-8")) as Record<
string,
Record<string, CatalogModel>
>;
const byId = new Map<string, CatalogModel>();
for (const api of Object.values(catalog)) for (const [id, m] of Object.entries(api)) byId.set(id, m);
const wanted = Object.values(MODELS).map((m) => m.model);
const all = [...new Set([...wanted, ...wanted.flatMap((id) => VARIANTS[id] ?? [])])];
const priced = new Map<string, { price: string; limit: string }>();
for (const id of all) {
const model = byId.get(id);
if (!model) {
console.warn(`!! ${id}: not on OpenRouter — entry omitted (cost renders as "—")`);
continue;
}
const limit = model.context_length;
priced.set(id, {
price: priceLine(id, model),
limit: `\t${JSON.stringify(id)}: ${limit ?? "undefined"},`,
});
}
// Sort by id for a stable, diff-friendly generated table.
const priced: { price: string; limit: string }[] = [...byId.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, m]) => ({
price: `\t${JSON.stringify(id)}: { input: ${m.cost.input}, output: ${m.cost.output}, cacheRead: ${m.cost.cacheRead}, cacheWrite: ${m.cost.cacheWrite} },`,
limit: `\t${JSON.stringify(id)}: ${m.contextWindow},`,
}));
const tables = `/** Token prices, USD per 1M tokens. A missing model makes cost render
* as absent ("—"), never zero. Refresh: npx tsx scripts/sync-pricing.ts */
* as absent ("—"), never zero. Refresh: bump @earendil-works/pi-ai, then
* npx tsx scripts/sync-pricing.ts (reads pi-ai's shipped catalog — the
* same source the engine and the picker use). */
export const PRICES: Record<string, ModelPrice> = {
${[...priced.values()].map((p) => p.price).join("\n")}
${priced.map((p) => p.price).join("\n")}
};
/** Context-window limits (tokens) per model — generated alongside PRICES. */
export const CONTEXT_LIMITS: Record<string, number> = {
${[...priced.values()].map((p) => p.limit).join("\n")}
${priced.map((p) => p.limit).join("\n")}
};
`;
@@ -89,8 +76,7 @@ ${[...priced.values()].map((p) => p.limit).join("\n")}
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");
console.log(`prices.ts regenerated: ${all.length} models checked, ${priced.size} priced.`);
for (const id of all) if (!priced.has(id)) console.warn(` unpriced: ${id}`);
console.log(`prices.ts regenerated from pi-ai's catalog: ${priced.length} models priced.`);
}
void main();
main();
+26 -10
View File
@@ -1,4 +1,5 @@
import {
clampThinkingLevel,
createModels,
type Api,
type Model,
@@ -8,6 +9,7 @@ import {
import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { MODELS, type ModelRole } from "../../config.ts";
import type { ChatSettings } from "../core/contracts/chat.ts";
/**
* The one engine factory every agent's model comes from (the pi-ai swap,
@@ -25,14 +27,17 @@ import { MODELS, type ModelRole } from "../../config.ts";
export interface PiEngine {
model: Model<Api>;
streamFn: StreamFunction;
/** The resolved (config-or-settings, CLAMPED) thinking level —
* observability + tests; the streamFn pins it anyway. */
level?: ThinkingLevel;
}
export type { ThinkingLevel };
/** config's effort scale maps 1:1 onto pi's thinking levels (none→off). */
/** The role's config thinking level (config's scale IS pi's since the
* model-control wave — no mapping needed). */
export function thinkingLevelFor(role: ModelRole): ThinkingLevel {
const effort = MODELS[role].effort as string;
return effort === "none" ? "off" : (effort as ThinkingLevel);
return MODELS[role].effort;
}
let collection: ReturnType<typeof createModels> | undefined;
@@ -51,18 +56,29 @@ export function openRouterApiKey(): string | undefined {
return process.env.OPENROUTER_API_KEY || undefined;
}
/** The production engine for one agent role (config.ts MODELS). */
export function piEngineFor(role: ModelRole): PiEngine {
/** The production engine for one agent role. `settings` is the chat's
* model-control override (prober/teacher only — sub-agent callers pass
* nothing and get config.ts's pin): any absent field falls back to the
* config spec, and the thinking level is CLAMPED to the model's supported
* list (pi's thinkingLevelMap — e.g. GLM-5.3-flash accepts low|high|max),
* so a settings change between models can never send an invalid level. */
export function piEngineFor(role: ModelRole, settings?: ChatSettings): PiEngine {
const models: Models = piModels();
const spec = MODELS[role];
const model = models.getModel("openrouter", spec.model);
if (!model) throw new Error(`OpenRouter catalog has no model ${spec.model} (role ${role})`);
const level = thinkingLevelFor(role);
const modelId = settings?.model?.id ?? spec.model;
const providerId = settings?.model?.provider ?? "openrouter";
const model = models.getModel(providerId, modelId);
if (!model) {
throw new Error(`${providerId} catalog has no model ${modelId} (role ${role})`);
}
const requested = settings?.thinkingLevel ?? thinkingLevelFor(role);
const level = clampThinkingLevel(model, requested);
return {
model,
level,
// bound streamSimple: the agent loop forwards state.thinkingLevel as
// `reasoning` (undefined = off on pi-ai's scale); pin the role's level
// so a bare engine can never drift.
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
// the pin honest even when config or settings drift off the map.
streamFn: (requestModel, context, options) =>
models.streamSimple(requestModel, context, {
...options,
+1
View File
@@ -14,3 +14,4 @@ export * from "./contracts/graph.ts";
export * from "./contracts/chat.ts";
export * from "./contracts/jobs.ts";
export * from "./contracts/probe.ts";
export * from "./contracts/models.ts";
+24
View File
@@ -7,6 +7,7 @@
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
*/
import type { UIMessage } from "ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Phase3 } from "../phases.ts";
import type { PlanNodeStatus, PlanTree } from "./plan.ts";
import type { EmissionStats } from "./graph.ts";
@@ -38,6 +39,25 @@ export interface ChatRecord {
* spend too. Same parallel-log discipline as `turns` (nothing rides
* inside messages). Keyed by startedAt; idempotent appends. */
extractions?: ExtractionRunUsage[];
/** Per-chat model/thinking overrides for the conversation agents. */
settings?: ChatSettings;
}
/** The conversation agents' runtime model choice (model-control wave,
* 2026-09-12): a per-chat override of config.ts's pinned prober/teacher
* spec. Absent fields fall back to config; written by the model picker
* (PUT /api/chats/[id]/settings) and read at turn composition — the
* agents are stateless per turn, so a change takes effect on the next
* turn by construction. Background sub-agents are NEVER overridden
* (config-pinned: they're infrastructure, not conversation). */
export interface ChatSettings {
/** pi-ai catalog coordinates: the provider that owns the model. Only
* "openrouter" is configured today; the field exists so a second
* provider needs no schema change. */
model?: { provider: string; id: string };
/** pi thinking level — must be in the chosen model's supported list
* (getSupportedThinkingLevels; enforced by the settings route). */
thinkingLevel?: ThinkingLevel;
}
// ─── Background-extraction usage (the money channel, extraction side) ────────
@@ -191,6 +211,10 @@ export interface ChatSummary {
phase: Phase3;
/** An unanswered quiz/ask question is waiting for the learner. */
pendingQuestion: boolean;
/** The record's model-control settings — rides the list payload via the
* summary spread (the picker never needs it from here; the full record
* is its source). */
settings?: ChatSettings;
}
/** The sidebar's session row: a summary + live liveness. The chats list
+39
View File
@@ -0,0 +1,39 @@
/**
* Model-catalog contracts (model-control wave, 2026-09-12) — the client-safe
* shapes of GET /api/models and PUT /api/chats/[id]/settings responses.
* Pure types; the server projection lives in src/server/model-catalog.ts
* (which OWNS these shapes — the contract is defined here only so client
* components never import from the server band).
*/
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
/** 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
* units core/prices.ts's generated table carries). */
export interface ModelCatalogEntry {
id: string;
name: string;
provider: string;
reasoning: boolean;
input: ("text" | "image")[];
/** The thinking levels this model actually accepts (pi's
* thinkingLevelMap — e.g. GLM-5.3-flash: low|high|max). The thinking
* picker lists exactly these; anything else is a lie. */
supportedLevels: ThinkingLevel[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
}
/** The conversation agents' config pin (config.ts MODELS prober/teacher —
* one controller, so one pin): what a chat without settings runs. */
export interface CatalogDefaults {
model: { provider: string; id: string };
thinkingLevel: ThinkingLevel;
}
/** GET /api/models response. */
export interface ModelCatalogResponse {
models: ModelCatalogEntry[];
defaults: CatalogDefaults;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Fuzzy search scoring (model-control wave, 2026-09-12) — pi's `/model`
* picker pattern: space-separated tokens, each token must match SOME field
* (case-insensitive substring), ranked so exact field matches beat
* prefixes beat buried substrings. Pure core; the picker sorts by score.
*/
/** Score one candidate, or null when any token matches no field.
* Higher is better; ties break naturally by list order (stable sort).
*
* Per-token points: exact field equality 100 · field startsWith 60 ·
* substring 30 minus a depth penalty (earlier finds win). Whole-query
* contiguity in one field adds +50 (query "glm flash" vs the id
* "z-ai/glm-5.3-flash" loses to a model whose id contains "glm flash"
* verbatim — as it should). */
export function fuzzyScore(query: string, ...fields: string[]): number | null {
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return 0;
const hay = fields.map((f) => f.toLowerCase());
let total = 0;
for (const token of tokens) {
let best = -1;
for (const f of hay) {
if (f === token) best = Math.max(best, 100);
else if (f.startsWith(token)) best = Math.max(best, 60);
else {
const at = f.indexOf(token);
if (at !== -1) best = Math.max(best, 30 - Math.min(at, 27));
}
}
if (best < 0) return null;
total += best;
}
const joined = tokens.join(" ");
if (hay.some((f) => f.includes(joined))) total += 50;
return total;
}
+1
View File
@@ -26,3 +26,4 @@ export * from "./math-delims.ts";
export * from "./ui-message.ts";
export * from "./tool-parts.ts";
export * from "./prices.ts";
export * from "./fuzzy.ts";
+4
View File
@@ -12,6 +12,7 @@
import type { UIMessage } from "ai";
import type {
ChatRecord,
ChatSettings,
ChatSummary,
GraphEdge,
GraphNode,
@@ -45,6 +46,9 @@ export interface ChatStore {
appendTurnUsage(id: string, entry: TurnUsage): void;
/** Append one extraction-run usage entry — idempotent per startedAt. */
appendExtractionUsage(id: string, entry: ExtractionRunUsage): void;
/** Persist the per-chat model/thinking settings (the model control).
* Replace-whole: the picker sends the complete settings object. */
saveSettings(id: string, settings: ChatSettings): void;
}
// ─── GraphStore — the append-only evidence ledgers (data/graph) ──────────────
+733 -3
View File
@@ -28,16 +28,746 @@ export interface ModelPrice {
}
/** Token prices, USD per 1M tokens. A missing model makes cost render
* as absent ("—"), never zero. Refresh: npx tsx scripts/sync-pricing.ts */
* as absent ("—"), never zero. Refresh: bump @earendil-works/pi-ai, then
* npx tsx scripts/sync-pricing.ts (reads pi-ai's shipped catalog — the
* same source the engine and the picker use). */
export const PRICES: Record<string, ModelPrice> = {
"z-ai/glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03 },
"~anthropic/claude-fable-latest": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
"~anthropic/claude-haiku-latest": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
"~anthropic/claude-opus-latest": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"~anthropic/claude-sonnet-latest": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"~deepseek/deepseek-v4-flash-latest": { input: 0.04998, output: 0.09996, cacheRead: 0.009996, cacheWrite: 0 },
"~google/gemini-flash-latest": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0.041667 },
"~google/gemini-pro-latest": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0.375 },
"~moonshotai/kimi-latest": { input: 2.55, output: 12.75, cacheRead: 0.256, cacheWrite: 0 },
"~openai/gpt-latest": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"~openai/gpt-mini-latest": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
"~x-ai/grok-latest": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 },
"~z-ai/glm-flash-latest": { input: 0.075, output: 0.25, cacheRead: 0.015, cacheWrite: 0 },
"~z-ai/glm-latest": { input: 1.15, output: 3.5, cacheRead: 0.1, cacheWrite: 0 },
"aion-labs/aion-2.0": { input: 0.8, output: 1.6, cacheRead: 0.2, cacheWrite: 0 },
"aion-labs/aion-3.0": { input: 3, output: 6, cacheRead: 0.75, cacheWrite: 0 },
"aion-labs/aion-3.0-mini": { input: 0.7, output: 1.4, cacheRead: 0.18, cacheWrite: 0 },
"amazon/nova-2-lite-v1": { input: 0.3, output: 2.5, cacheRead: 0, cacheWrite: 0 },
"amazon/nova-lite-v1": { input: 0.06, output: 0.24, cacheRead: 0, cacheWrite: 0 },
"amazon/nova-micro-v1": { input: 0.035, output: 0.14, cacheRead: 0, cacheWrite: 0 },
"amazon/nova-premier-v1": { input: 2.5, output: 12.5, cacheRead: 0.625, cacheWrite: 0 },
"amazon/nova-pro-v1": { input: 0.8, output: 3.2, cacheRead: 0, cacheWrite: 0 },
"anthropic/claude-3-haiku": { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 },
"anthropic/claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
"anthropic/claude-fable-5:batch": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"anthropic/claude-fable-5.1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
"anthropic/claude-fable-5.1:batch": { input: 5, output: 25, cacheRead: 0.125, cacheWrite: 6.25 },
"anthropic/claude-haiku-4.5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
"anthropic/claude-haiku-4.5:batch": { input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 },
"anthropic/claude-opus-4": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
"anthropic/claude-opus-4.1": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
"anthropic/claude-opus-4.1:batch": { input: 7.5, output: 37.5, cacheRead: 0.75, cacheWrite: 9.375 },
"anthropic/claude-opus-4.5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"anthropic/claude-opus-4.5:batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 },
"anthropic/claude-opus-4.6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"anthropic/claude-opus-4.6:batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 },
"anthropic/claude-opus-4.7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"anthropic/claude-opus-4.7:batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 },
"anthropic/claude-opus-4.8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"anthropic/claude-opus-4.8:batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 },
"anthropic/claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"anthropic/claude-opus-5:batch": { input: 2.5, output: 12.5, cacheRead: 0.25, cacheWrite: 3.125 },
"anthropic/claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"anthropic/claude-sonnet-4.5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"anthropic/claude-sonnet-4.5:batch": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 1.875 },
"anthropic/claude-sonnet-4.6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"anthropic/claude-sonnet-4.6:batch": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 1.875 },
"anthropic/claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"anthropic/claude-sonnet-5:batch": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
"arcee-ai/trinity-large-thinking": { input: 0.25, output: 0.8, cacheRead: 0.06, cacheWrite: 0 },
"auto": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"bytedance-seed/seed-1.6": { input: 0.25, output: 2, cacheRead: 0, cacheWrite: 0 },
"bytedance-seed/seed-1.6-flash": { input: 0.075, output: 0.3, cacheRead: 0, cacheWrite: 0 },
"bytedance-seed/seed-2-1-turbo": { input: 0.5, output: 2.5, cacheRead: 0, cacheWrite: 0 },
"bytedance-seed/seed-2.0-code": { input: 0.5, output: 3, cacheRead: 0, cacheWrite: 0 },
"bytedance-seed/seed-2.0-lite": { input: 0.25, output: 2, cacheRead: 0, cacheWrite: 0 },
"bytedance-seed/seed-2.0-mini": { input: 0.1, output: 0.4, cacheRead: 0, cacheWrite: 0 },
"cohere/command-r-08-2024": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 },
"cohere/command-r-plus-08-2024": { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 },
"cohere/north-mini-code:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"deepseek/deepseek-chat": { input: 0.32, output: 0.89, cacheRead: 0, cacheWrite: 0 },
"deepseek/deepseek-chat-v3-0324": { input: 0.25, output: 1, cacheRead: 0, cacheWrite: 0 },
"deepseek/deepseek-chat-v3.1": { input: 0.55, output: 1.65, cacheRead: 0.55, cacheWrite: 0 },
"deepseek/deepseek-r1": { input: 0.7, output: 2.5, cacheRead: 0, cacheWrite: 0 },
"deepseek/deepseek-r1-0528": { input: 0.5, output: 2.15, cacheRead: 0.35, cacheWrite: 0 },
"deepseek/deepseek-v3.1-terminus": { input: 0.27, output: 1, cacheRead: 0.135, cacheWrite: 0 },
"deepseek/deepseek-v3.2": { input: 0.269, output: 0.4, cacheRead: 0.1345, cacheWrite: 0 },
"deepseek/deepseek-v3.2-exp": { input: 0.27, output: 0.41, cacheRead: 0, cacheWrite: 0 },
"deepseek/deepseek-v4-flash": { input: 0.08526, output: 0.17052, cacheRead: 0.017052, cacheWrite: 0 },
"deepseek/deepseek-v4-flash-0731": { input: 0.065, output: 0.18, cacheRead: 0.016, cacheWrite: 0 },
"deepseek/deepseek-v4-flash-0731:batch": { input: 0.14, output: 0.28, cacheRead: 0.03, cacheWrite: 0 },
"deepseek/deepseek-v4-flash-vision-exp": { input: 0.22, output: 0.66, cacheRead: 0.007, cacheWrite: 0 },
"deepseek/deepseek-v4-pro": { input: 0.890358, output: 1.780716, cacheRead: 0.074196, cacheWrite: 0 },
"deepseek/deepseek-v4-pro-0813": { input: 1.12068, output: 3.36204, cacheRead: 0.037356, cacheWrite: 0 },
"deepseek/deepseek-v4-pro-0813:batch": { input: 1.32, output: 3.96, cacheRead: 0.13, cacheWrite: 0 },
"dots-studio/dots-3-note-preview:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"google/gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0.083333 },
"google/gemini-2.5-flash-lite": { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0.083333 },
"google/gemini-2.5-flash-lite:batch": { input: 0.05, output: 0.2, cacheRead: 0.01, cacheWrite: 0 },
"google/gemini-2.5-flash:batch": { input: 0.15, output: 1.25, cacheRead: 0.03, cacheWrite: 0 },
"google/gemini-2.5-pro": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.375 },
"google/gemini-2.5-pro-preview": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.375 },
"google/gemini-2.5-pro-preview-05-06": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.375 },
"google/gemini-2.5-pro:batch": { input: 0.625, output: 5, cacheRead: 0.125, cacheWrite: 0 },
"google/gemini-3-flash-preview": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.083333 },
"google/gemini-3-flash-preview:batch": { input: 0.25, output: 1.5, cacheRead: 0, cacheWrite: 0 },
"google/gemini-3-pro-image": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0.375 },
"google/gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.025, cacheWrite: 0.083333 },
"google/gemini-3.1-flash-lite-preview": { input: 0.25, output: 1.5, cacheRead: 0.025, cacheWrite: 0.083333 },
"google/gemini-3.1-flash-lite:batch": { input: 0.125, output: 0.75, cacheRead: 0.0125, cacheWrite: 0 },
"google/gemini-3.1-pro-preview": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0.375 },
"google/gemini-3.1-pro-preview-customtools": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0.375 },
"google/gemini-3.1-pro-preview:batch": { input: 1, output: 6, cacheRead: 0, cacheWrite: 0 },
"google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0.083333 },
"google/gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0.083333 },
"google/gemini-3.5-flash-lite:batch": { input: 0.15, output: 1.25, cacheRead: 0.015, cacheWrite: 0 },
"google/gemini-3.5-flash:batch": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
"google/gemini-3.6-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0.041667 },
"google/gemini-3.6-flash:batch": { input: 0.375, output: 1.875, cacheRead: 0.0375, cacheWrite: 0.041667 },
"google/gemini-3.7-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0.041667 },
"google/gemini-3.7-flash:batch": { input: 0.375, output: 1.875, cacheRead: 0.0375, cacheWrite: 0.041667 },
"google/gemini-3.8-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0.041667 },
"google/gemini-3.8-flash:batch": { input: 0.375, output: 1.875, cacheRead: 0.0375, cacheWrite: 0.041667 },
"google/gemma-3-12b-it": { input: 0.05, output: 0.15, cacheRead: 0, cacheWrite: 0 },
"google/gemma-3-27b-it": { input: 0.08, output: 0.45, cacheRead: 0.04, cacheWrite: 0 },
"google/gemma-4-26b-a4b-it": { input: 0.07, output: 0.34, cacheRead: 0, cacheWrite: 0 },
"google/gemma-4-26b-a4b-it:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"google/gemma-4-31b-it": { input: 0.09, output: 0.34, cacheRead: 0.05, cacheWrite: 0 },
"google/gemma-4-31b-it:batch": { input: 0.39, output: 0.97, cacheRead: 0, cacheWrite: 0 },
"google/gemma-4-31b-it:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"ibm-granite/granite-4.2-8b": { input: 0.1, output: 0.15, cacheRead: 0.05, cacheWrite: 0 },
"inception/mercury-2": { input: 0.25, output: 0.75, cacheRead: 0.025, cacheWrite: 0 },
"inception/mercury-2.5-preview": { input: 0.04, output: 0.15, cacheRead: 0.004, cacheWrite: 0 },
"inclusionai/ling-3.0-flash": { input: 0.021, output: 0.063, cacheRead: 0.0042, cacheWrite: 0 },
"inclusionai/ling-3.0-flash-fin": { input: 0.06, output: 0.18, cacheRead: 0.012, cacheWrite: 0 },
"inclusionai/ling-3.0-flash-fin:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"inclusionai/ling-3.0-flash-sante:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"kwaipilot/kat-coder-pro-v2": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
"kwaipilot/kat-coder-pro-v2.5": { input: 0.74, output: 2.96, cacheRead: 0.15, cacheWrite: 0 },
"liquid/lfm-2.5-2.6b:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"meituan/longcat-2.0": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 },
"meta-llama/llama-3.1-70b-instruct": { input: 0.4, output: 0.4, cacheRead: 0, cacheWrite: 0 },
"meta-llama/llama-3.1-8b-instruct": { input: 0.05, output: 0.08, cacheRead: 0.025, cacheWrite: 0 },
"meta-llama/llama-3.3-70b-instruct": { input: 0.1, output: 0.32, cacheRead: 0, cacheWrite: 0 },
"meta-llama/llama-4-maverick": { input: 0.2, output: 0.696, cacheRead: 0, cacheWrite: 0 },
"meta-llama/llama-4-scout": { input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0 },
"meta/muse-glimmer-30b": { input: 0.3, output: 1.1, cacheRead: 0.04, cacheWrite: 0 },
"meta/muse-glimmer-30b:batch": { input: 0.35, output: 1.5, cacheRead: 0.04, cacheWrite: 0 },
"meta/muse-spark-1.1": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
"meta/muse-spark-1.2": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
"meta/muse-spark-1.2-contributor": { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
"meta/muse-spark-1.3": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
"meta/muse-spark-1.3-contributor": { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
"minimax/minimax-m1": { input: 0.55, output: 2.2, cacheRead: 0, cacheWrite: 0 },
"minimax/minimax-m2": { input: 0.255, output: 1.02, cacheRead: 0, cacheWrite: 0 },
"minimax/minimax-m2.1": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 },
"minimax/minimax-m2.5": { input: 0.27, output: 1.08, cacheRead: 0.027, cacheWrite: 0 },
"minimax/minimax-m2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
"minimax/minimax-m2.7:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"minimax/minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
"minimax/minimax-m3:batch": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
"minimax/minimax-m3:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"mistralai/codestral-2508": { input: 0.3, output: 0.9, cacheRead: 0.03, cacheWrite: 0 },
"mistralai/devstral-2512": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 },
"mistralai/ministral-14b-2512": { input: 0.2, output: 0.2, cacheRead: 0.02, cacheWrite: 0 },
"mistralai/ministral-3b-2512": { input: 0.1, output: 0.1, cacheRead: 0.01, cacheWrite: 0 },
"mistralai/ministral-8b-2512": { input: 0.15, output: 0.15, cacheRead: 0.015, cacheWrite: 0 },
"mistralai/mistral-large": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 },
"mistralai/mistral-large-2407": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 },
"mistralai/mistral-large-2512": { input: 0.5, output: 1.5, cacheRead: 0.05, cacheWrite: 0 },
"mistralai/mistral-medium-3": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 },
"mistralai/mistral-medium-3-5": { input: 1.5, output: 7.5, cacheRead: 0, cacheWrite: 0 },
"mistralai/mistral-medium-3-5:batch": { input: 0.75, output: 3.75, cacheRead: 0, cacheWrite: 0 },
"mistralai/mistral-medium-3.1": { input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0 },
"mistralai/mistral-nemo": { input: 0.019, output: 0.03, cacheRead: 0, cacheWrite: 0 },
"mistralai/mistral-saba": { input: 0.2, output: 0.6, cacheRead: 0.02, cacheWrite: 0 },
"mistralai/mistral-small-2603": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 },
"mistralai/mistral-small-3.2-24b-instruct": { input: 0.075, output: 0.2, cacheRead: 0, cacheWrite: 0 },
"mistralai/mixtral-8x22b-instruct": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 },
"mistralai/voxtral-small-24b-2507": { input: 0.1, output: 0.3, cacheRead: 0.01, cacheWrite: 0 },
"moonshotai/kimi-k2": { input: 0.57, output: 2.3, cacheRead: 0, cacheWrite: 0 },
"moonshotai/kimi-k2-0905": { input: 0.6, output: 2.5, cacheRead: 0, cacheWrite: 0 },
"moonshotai/kimi-k2-thinking": { input: 0.6, output: 2.5, cacheRead: 0.15, cacheWrite: 0 },
"moonshotai/kimi-k2.5": { input: 0.41, output: 2.06, cacheRead: 0.07, cacheWrite: 0 },
"moonshotai/kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
"moonshotai/kimi-k2.7-code": { input: 0.66, output: 3.4, cacheRead: 0.18, cacheWrite: 0 },
"moonshotai/kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
"moonshotai/kimi-k3:batch": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
"nex-agi/nex-n2-mini": { input: 0.025, output: 0.1, cacheRead: 0.0025, cacheWrite: 0 },
"nex-agi/nex-n2-pro": { input: 0.25, output: 1, cacheRead: 0.025, cacheWrite: 0 },
"nvidia/nemotron-3-nano-30b-a3b": { input: 0.05, output: 0.2, cacheRead: 0.03, cacheWrite: 0 },
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"nvidia/nemotron-3-super-120b-a12b": { input: 0.085, output: 0.4, cacheRead: 0, cacheWrite: 0 },
"nvidia/nemotron-3-super-120b-a12b:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"nvidia/nemotron-3-ultra-550b-a55b": { input: 0.625, output: 3.125, cacheRead: 0.1875, cacheWrite: 0 },
"nvidia/nemotron-3-ultra-550b-a55b:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"nvidia/nemotron-3.5-lightning": { input: 0.08, output: 0.2, cacheRead: 0.04, cacheWrite: 0 },
"nvidia/nemotron-3.5-lightning:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-3.5-turbo": { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-3.5-turbo-0613": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-3.5-turbo-16k": { input: 3, output: 4, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-3.5-turbo:batch": { input: 0.25, output: 0.75, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-4": { input: 30, output: 60, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-4-turbo": { input: 10, output: 30, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-4-turbo-preview": { input: 10, output: 30, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-4-turbo:batch": { input: 5, output: 15, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-4.1": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 },
"openai/gpt-4.1-mini": { input: 0.4, output: 1.6, cacheRead: 0.1, cacheWrite: 0 },
"openai/gpt-4.1-mini:batch": { input: 0.2, output: 0.8, cacheRead: 0.05, cacheWrite: 0 },
"openai/gpt-4.1-nano": { input: 0.1, output: 0.4, cacheRead: 0.025, cacheWrite: 0 },
"openai/gpt-4.1-nano:batch": { input: 0.05, output: 0.2, cacheRead: 0.0125, cacheWrite: 0 },
"openai/gpt-4.1:batch": { input: 1, output: 4, cacheRead: 0.25, cacheWrite: 0 },
"openai/gpt-4o": { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 },
"openai/gpt-4o-2024-05-13": { input: 5, output: 15, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-4o-2024-08-06": { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 },
"openai/gpt-4o-2024-11-20": { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 },
"openai/gpt-4o-mini": { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0 },
"openai/gpt-4o-mini-2024-07-18": { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0 },
"openai/gpt-4o-mini:batch": { input: 0.075, output: 0.3, cacheRead: 0.0375, cacheWrite: 0 },
"openai/gpt-4o:batch": { input: 1.25, output: 5, cacheRead: 0.625, cacheWrite: 0 },
"openai/gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
"openai/gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
"openai/gpt-5-mini:batch": { input: 0.125, output: 1, cacheRead: 0.0125, cacheWrite: 0 },
"openai/gpt-5-nano": { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 },
"openai/gpt-5-nano:batch": { input: 0.025, output: 0.2, cacheRead: 0.0025, cacheWrite: 0 },
"openai/gpt-5-pro": { input: 15, output: 120, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-5-pro:batch": { input: 7.5, output: 60, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-5:batch": { input: 0.625, output: 5, cacheRead: 0.0625, cacheWrite: 0 },
"openai/gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
"openai/gpt-5.1-codex": { input: 1.25, output: 10, cacheRead: 0.13, cacheWrite: 0 },
"openai/gpt-5.1-codex-max": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
"openai/gpt-5.1-codex-mini": { input: 0.25, output: 2, cacheRead: 0.03, cacheWrite: 0 },
"openai/gpt-5.1:batch": { input: 0.625, output: 5, cacheRead: 0.0625, cacheWrite: 0 },
"openai/gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
"openai/gpt-5.2-chat": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
"openai/gpt-5.2-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
"openai/gpt-5.2-pro": { input: 21, output: 168, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-5.2-pro:batch": { input: 10.5, output: 84, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-5.2:batch": { input: 0.875, output: 7, cacheRead: 0.0875, cacheWrite: 0 },
"openai/gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
"openai/gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
"openai/gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
"openai/gpt-5.4-mini:batch": { input: 0.375, output: 2.25, cacheRead: 0.0375, cacheWrite: 0 },
"openai/gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 },
"openai/gpt-5.4-nano:batch": { input: 0.1, output: 0.625, cacheRead: 0.01, cacheWrite: 0 },
"openai/gpt-5.4-pro": { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-5.4-pro:batch": { input: 15, output: 90, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-5.4:batch": { input: 1.25, output: 7.5, cacheRead: 0.125, cacheWrite: 0 },
"openai/gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
"openai/gpt-5.5-pro": { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-5.5-pro:batch": { input: 15, output: 90, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-5.5:batch": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
"openai/gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
"openai/gpt-5.6-luna-pro": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
"openai/gpt-5.6-luna-pro:batch": { input: 0.1, output: 0.6, cacheRead: 0.01, cacheWrite: 0 },
"openai/gpt-5.6-luna:batch": { input: 0.1, output: 0.6, cacheRead: 0.01, cacheWrite: 0 },
"openai/gpt-5.6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"openai/gpt-5.6-sol-pro": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"openai/gpt-5.6-sol-pro:batch": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
"openai/gpt-5.6-sol:batch": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
"openai/gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 },
"openai/gpt-5.6-terra-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 },
"openai/gpt-5.6-terra-pro:batch": { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 0 },
"openai/gpt-5.6-terra:batch": { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 0 },
"openai/gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
"openai/gpt-6-astra-pro": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
"openai/gpt-6-astra-pro:batch": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"openai/gpt-6-astra:batch": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"openai/gpt-audio": { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-audio-mini": { input: 0.6, output: 2.4, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-chat-latest": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
"openai/gpt-oss-120b": { input: 0.037, output: 0.17, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-oss-120b:batch": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 },
"openai/gpt-oss-20b": { input: 0.03, output: 0.13, cacheRead: 0.03, cacheWrite: 0 },
"openai/gpt-oss-safeguard-20b": { input: 0.075, output: 0.3, cacheRead: 0.0375, cacheWrite: 0 },
"openai/o1": { input: 15, output: 60, cacheRead: 7.5, cacheWrite: 0 },
"openai/o3": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 },
"openai/o3-mini": { input: 1.1, output: 4.4, cacheRead: 0.55, cacheWrite: 0 },
"openai/o3-mini-high": { input: 1.1, output: 4.4, cacheRead: 0.55, cacheWrite: 0 },
"openai/o3-mini:batch": { input: 0.55, output: 2.2, cacheRead: 0.275, cacheWrite: 0 },
"openai/o3-pro": { input: 20, output: 80, cacheRead: 0, cacheWrite: 0 },
"openai/o3:batch": { input: 1, output: 4, cacheRead: 0.25, cacheWrite: 0 },
"openai/o4-mini": { input: 1.1, output: 4.4, cacheRead: 0.275, cacheWrite: 0 },
"openai/o4-mini-high": { input: 1.1, output: 4.4, cacheRead: 0.275, cacheWrite: 0 },
"openai/o4-mini:batch": { input: 0.55, output: 2.2, cacheRead: 0.1375, cacheWrite: 0 },
"openrouter/auto": { input: -1000000, output: -1000000, cacheRead: 0, cacheWrite: 0 },
"openrouter/auto-beta": { input: -1000000, output: -1000000, cacheRead: 0, cacheWrite: 0 },
"openrouter/free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"openrouter/fusion": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"poolside/laguna-s-2.1": { input: 0.09, output: 0.18, cacheRead: 0.009, cacheWrite: 0 },
"poolside/laguna-s-2.1:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"poolside/laguna-xs-2.1": { input: 0.06, output: 0.12, cacheRead: 0.03, cacheWrite: 0 },
"poolside/laguna-xs-2.1:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen-2.5-72b-instruct": { input: 0.36, output: 0.4, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen-2.5-7b-instruct": { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen-plus": { input: 0.26, output: 0.78, cacheRead: 0.052, cacheWrite: 0.325 },
"qwen/qwen-plus-2025-07-28": { input: 0.26, output: 0.78, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-14b": { input: 0.12, output: 0.24, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-235b-a22b": { input: 0.455, output: 1.82, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-235b-a22b-2507": { input: 0.09, output: 0.55, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-235b-a22b-thinking-2507": { input: 0.23, output: 2.3, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-30b-a3b": { input: 0.12, output: 0.5, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-30b-a3b-instruct-2507": { input: 0.04815, output: 0.19305, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-30b-a3b-thinking-2507": { input: 0.2, output: 2.4, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-32b": { input: 0.08, output: 0.28, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-8b": { input: 0.117, output: 0.455, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-coder": { input: 0.3, output: 1, cacheRead: 0.1, cacheWrite: 0 },
"qwen/qwen3-coder-30b-a3b-instruct": { input: 0.07, output: 0.28, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-coder-flash": { input: 0.195, output: 0.975, cacheRead: 0.039, cacheWrite: 0.24375 },
"qwen/qwen3-coder-next": { input: 0.12, output: 0.8, cacheRead: 0.07, cacheWrite: 0 },
"qwen/qwen3-coder-plus": { input: 0.65, output: 3.25, cacheRead: 0.13, cacheWrite: 0.8125 },
"qwen/qwen3-max": { input: 0.78, output: 3.9, cacheRead: 0.156, cacheWrite: 0.975 },
"qwen/qwen3-max-thinking": { input: 0.78, output: 3.9, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-next-80b-a3b-instruct": { input: 0.1, output: 1.1, cacheRead: 0.07, cacheWrite: 0 },
"qwen/qwen3-next-80b-a3b-thinking": { input: 0.15, output: 1.2, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-vl-235b-a22b-instruct": { input: 0.21, output: 1.9, cacheRead: 0.1, cacheWrite: 0 },
"qwen/qwen3-vl-235b-a22b-thinking": { input: 0.4, output: 4, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-vl-30b-a3b-instruct": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-vl-30b-a3b-thinking": { input: 0.2, output: 2.4, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-vl-32b-instruct": { input: 0.104, output: 0.416, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-vl-8b-instruct": { input: 0.117, output: 0.455, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3-vl-8b-thinking": { input: 0.18, output: 2.1, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3.5-122b-a10b": { input: 0.29, output: 2.4, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3.5-27b": { input: 0.195, output: 1.56, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3.5-35b-a3b": { input: 0.08, output: 0.75, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3.5-397b-a17b": { input: 0.55, output: 3.5, cacheRead: 0.225, cacheWrite: 0 },
"qwen/qwen3.5-9b": { input: 0.1, output: 0.15, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3.5-9b:batch": { input: 0.17, output: 0.25, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3.5-flash-02-23": { input: 0.065, output: 0.26, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3.5-plus-02-15": { input: 0.26, output: 1.56, cacheRead: 0, cacheWrite: 0 },
"qwen/qwen3.5-plus-20260420": { input: 0.3, output: 1.8, cacheRead: 0, cacheWrite: 0.375 },
"qwen/qwen3.6-27b": { input: 0.3, output: 2, cacheRead: 0.03, cacheWrite: 0 },
"qwen/qwen3.6-35b-a3b": { input: 0.1, output: 0.9, cacheRead: 0.05, cacheWrite: 0 },
"qwen/qwen3.6-flash": { input: 0.1875, output: 1.125, cacheRead: 0, cacheWrite: 0.234375 },
"qwen/qwen3.6-max-preview": { input: 1.027, output: 6.162, cacheRead: 0, cacheWrite: 1.28375 },
"qwen/qwen3.6-plus": { input: 0.325, output: 1.95, cacheRead: 0, cacheWrite: 0.40625 },
"qwen/qwen3.7-flash": { input: 0.03, output: 0.13, cacheRead: 0.006, cacheWrite: 0.038 },
"qwen/qwen3.7-max": { input: 1.475, output: 4.425, cacheRead: 0.295, cacheWrite: 1.84375 },
"qwen/qwen3.7-plus": { input: 0.32, output: 1.28, cacheRead: 0.064, cacheWrite: 0.4 },
"qwen/qwen3.8-2.4t-a95b": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 },
"qwen/qwen3.8-2.4t-a95b:batch": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 0 },
"qwen/qwen3.8-27b": { input: 0.42, output: 3, cacheRead: 0.085, cacheWrite: 0 },
"qwen/qwen3.8-flash": { input: 0.15, output: 0.47, cacheRead: 0.016, cacheWrite: 0.2 },
"qwen/qwen3.8-max-0902": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 },
"rekaai/reka-edge": { input: 0.1, output: 0.1, cacheRead: 0, cacheWrite: 0 },
"relace/relace-search": { input: 1, output: 3, cacheRead: 0, cacheWrite: 0 },
"sakana/fugu-ultra": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
"sakana/sakana-namazu": { input: 0.95, output: 4, cacheRead: 0.15, cacheWrite: 0 },
"sao10k/l3.1-euryale-70b": { input: 0.85, output: 0.85, cacheRead: 0, cacheWrite: 0 },
"stepfun/step-3.5-flash": { input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0 },
"stepfun/step-3.7-flash": { input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0 },
"tencent/hy3": { input: 0.132, output: 0.528, cacheRead: 0.033, cacheWrite: 0 },
"tencent/hy3-preview": { input: 0.18, output: 0.6, cacheRead: 0.06, cacheWrite: 0 },
"tencent/hy4-preview": { input: 0.834, output: 2.501, cacheRead: 0.042, cacheWrite: 0 },
"thedrummer/unslopnemo-12b": { input: 0.4, output: 0.4, cacheRead: 0, cacheWrite: 0 },
"thinkingmachines/inkling": { input: 1, output: 4.05, cacheRead: 0.17, cacheWrite: 0 },
"thinkingmachines/inkling-small": { input: 0.45, output: 1.2, cacheRead: 0.1, cacheWrite: 0 },
"thinkingmachines/inkling-small:batch": { input: 0.5, output: 1.2, cacheRead: 0.1, cacheWrite: 0 },
"thinkingmachines/inkling-small:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"thinkingmachines/inkling:batch": { input: 1, output: 4.05, cacheRead: 0.17, cacheWrite: 0 },
"thinkingmachines/inkling:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"upstage/solar-pro-3": { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 },
"upstage/solar-pro4": { input: 0.03, output: 0.12, cacheRead: 0.006, cacheWrite: 0 },
"x-ai/grok-4.20": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
"x-ai/grok-4.3": { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
"x-ai/grok-4.3:batch": { input: 1, output: 2, cacheRead: 0.16, cacheWrite: 0 },
"x-ai/grok-4.5": { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 },
"x-ai/grok-4.6": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 },
"x-ai/grok-build-0.1": { input: 1, output: 2, cacheRead: 0.2, cacheWrite: 0 },
"xiaomi/mimo-v2.5": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
"xiaomi/mimo-v2.5-pro": { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 },
"z-ai/glm-4.5": { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0 },
"z-ai/glm-4.5-air": { input: 0.13, output: 0.85, cacheRead: 0.025, cacheWrite: 0 },
"z-ai/glm-4.5v": { input: 0.6, output: 1.8, cacheRead: 0.11, cacheWrite: 0 },
"z-ai/glm-4.6": { input: 0.55, output: 2.2, cacheRead: 0.11, cacheWrite: 0 },
"z-ai/glm-4.6v": { input: 0.3, output: 0.9, cacheRead: 0.055, cacheWrite: 0 },
"z-ai/glm-4.7": { input: 0.4, output: 1.75, cacheRead: 0.08, cacheWrite: 0 },
"z-ai/glm-4.7-flash": { input: 0.06, output: 0.4, cacheRead: 0.01, cacheWrite: 0 },
"z-ai/glm-5": { input: 0.6, output: 1.9, cacheRead: 0.119, cacheWrite: 0 },
"z-ai/glm-5-turbo": { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 },
"z-ai/glm-5.1": { input: 0.966, output: 3.036, cacheRead: 0.1794, cacheWrite: 0 },
"z-ai/glm-5.2": { input: 0.966, output: 3.036, cacheRead: 0.1932, cacheWrite: 0 },
"z-ai/glm-5.2:free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"z-ai/glm-5.3": { input: 1.4, output: 4.4, cacheRead: 0.14, cacheWrite: 0 },
"z-ai/glm-5.3-flash": { input: 0.075, output: 0.25, cacheRead: 0.015, cacheWrite: 0 },
"z-ai/glm-5.3-flash:batch": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 },
"z-ai/glm-5v-turbo": { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 },
};
/** Context-window limits (tokens) per model — generated alongside PRICES. */
export const CONTEXT_LIMITS: Record<string, number> = {
"z-ai/glm-5.3-flash": 1310720,
"~anthropic/claude-fable-latest": 1000000,
"~anthropic/claude-haiku-latest": 200000,
"~anthropic/claude-opus-latest": 1000000,
"~anthropic/claude-sonnet-latest": 1000000,
"~deepseek/deepseek-v4-flash-latest": 1048576,
"~google/gemini-flash-latest": 1048576,
"~google/gemini-pro-latest": 1048576,
"~moonshotai/kimi-latest": 1048576,
"~openai/gpt-latest": 1050000,
"~openai/gpt-mini-latest": 400000,
"~x-ai/grok-latest": 500000,
"~z-ai/glm-flash-latest": 1048576,
"~z-ai/glm-latest": 262144,
"aion-labs/aion-2.0": 131072,
"aion-labs/aion-3.0": 131072,
"aion-labs/aion-3.0-mini": 131072,
"amazon/nova-2-lite-v1": 1000000,
"amazon/nova-lite-v1": 300000,
"amazon/nova-micro-v1": 128000,
"amazon/nova-premier-v1": 1000000,
"amazon/nova-pro-v1": 300000,
"anthropic/claude-3-haiku": 200000,
"anthropic/claude-fable-5": 1000000,
"anthropic/claude-fable-5:batch": 1000000,
"anthropic/claude-fable-5.1": 1000000,
"anthropic/claude-fable-5.1:batch": 1000000,
"anthropic/claude-haiku-4.5": 200000,
"anthropic/claude-haiku-4.5:batch": 200000,
"anthropic/claude-opus-4": 200000,
"anthropic/claude-opus-4.1": 200000,
"anthropic/claude-opus-4.1:batch": 200000,
"anthropic/claude-opus-4.5": 200000,
"anthropic/claude-opus-4.5:batch": 200000,
"anthropic/claude-opus-4.6": 1000000,
"anthropic/claude-opus-4.6:batch": 1000000,
"anthropic/claude-opus-4.7": 1000000,
"anthropic/claude-opus-4.7:batch": 1000000,
"anthropic/claude-opus-4.8": 1000000,
"anthropic/claude-opus-4.8:batch": 1000000,
"anthropic/claude-opus-5": 1000000,
"anthropic/claude-opus-5:batch": 1000000,
"anthropic/claude-sonnet-4": 200000,
"anthropic/claude-sonnet-4.5": 1000000,
"anthropic/claude-sonnet-4.5:batch": 1000000,
"anthropic/claude-sonnet-4.6": 1000000,
"anthropic/claude-sonnet-4.6:batch": 1000000,
"anthropic/claude-sonnet-5": 1000000,
"anthropic/claude-sonnet-5:batch": 1000000,
"arcee-ai/trinity-large-thinking": 262144,
"auto": 2000000,
"bytedance-seed/seed-1.6": 262144,
"bytedance-seed/seed-1.6-flash": 262144,
"bytedance-seed/seed-2-1-turbo": 262144,
"bytedance-seed/seed-2.0-code": 262144,
"bytedance-seed/seed-2.0-lite": 262144,
"bytedance-seed/seed-2.0-mini": 262144,
"cohere/command-r-08-2024": 128000,
"cohere/command-r-plus-08-2024": 128000,
"cohere/north-mini-code:free": 256000,
"deepseek/deepseek-chat": 163840,
"deepseek/deepseek-chat-v3-0324": 163840,
"deepseek/deepseek-chat-v3.1": 161000,
"deepseek/deepseek-r1": 64000,
"deepseek/deepseek-r1-0528": 163840,
"deepseek/deepseek-v3.1-terminus": 131072,
"deepseek/deepseek-v3.2": 163840,
"deepseek/deepseek-v3.2-exp": 163840,
"deepseek/deepseek-v4-flash": 1024000,
"deepseek/deepseek-v4-flash-0731": 1048576,
"deepseek/deepseek-v4-flash-0731:batch": 1048576,
"deepseek/deepseek-v4-flash-vision-exp": 1048576,
"deepseek/deepseek-v4-pro": 1024000,
"deepseek/deepseek-v4-pro-0813": 1024000,
"deepseek/deepseek-v4-pro-0813:batch": 1048576,
"dots-studio/dots-3-note-preview:free": 512000,
"google/gemini-2.5-flash": 1048576,
"google/gemini-2.5-flash-lite": 1048576,
"google/gemini-2.5-flash-lite:batch": 1048576,
"google/gemini-2.5-flash:batch": 1048576,
"google/gemini-2.5-pro": 1048576,
"google/gemini-2.5-pro-preview": 1048576,
"google/gemini-2.5-pro-preview-05-06": 1048576,
"google/gemini-2.5-pro:batch": 1048576,
"google/gemini-3-flash-preview": 1048576,
"google/gemini-3-flash-preview:batch": 1048576,
"google/gemini-3-pro-image": 65536,
"google/gemini-3.1-flash-lite": 1048576,
"google/gemini-3.1-flash-lite-preview": 1048576,
"google/gemini-3.1-flash-lite:batch": 1048576,
"google/gemini-3.1-pro-preview": 1048576,
"google/gemini-3.1-pro-preview-customtools": 1048576,
"google/gemini-3.1-pro-preview:batch": 1048576,
"google/gemini-3.5-flash": 1048576,
"google/gemini-3.5-flash-lite": 1048576,
"google/gemini-3.5-flash-lite:batch": 1048576,
"google/gemini-3.5-flash:batch": 1048576,
"google/gemini-3.6-flash": 1048576,
"google/gemini-3.6-flash:batch": 1048576,
"google/gemini-3.7-flash": 1048576,
"google/gemini-3.7-flash:batch": 1048576,
"google/gemini-3.8-flash": 1048576,
"google/gemini-3.8-flash:batch": 1048576,
"google/gemma-3-12b-it": 131072,
"google/gemma-3-27b-it": 131072,
"google/gemma-4-26b-a4b-it": 262144,
"google/gemma-4-26b-a4b-it:free": 262144,
"google/gemma-4-31b-it": 262144,
"google/gemma-4-31b-it:batch": 262144,
"google/gemma-4-31b-it:free": 262144,
"ibm-granite/granite-4.2-8b": 131072,
"inception/mercury-2": 128000,
"inception/mercury-2.5-preview": 260000,
"inclusionai/ling-3.0-flash": 262144,
"inclusionai/ling-3.0-flash-fin": 262144,
"inclusionai/ling-3.0-flash-fin:free": 262144,
"inclusionai/ling-3.0-flash-sante:free": 262144,
"kwaipilot/kat-coder-pro-v2": 262144,
"kwaipilot/kat-coder-pro-v2.5": 262144,
"liquid/lfm-2.5-2.6b:free": 65536,
"meituan/longcat-2.0": 1048756,
"meta-llama/llama-3.1-70b-instruct": 131072,
"meta-llama/llama-3.1-8b-instruct": 131072,
"meta-llama/llama-3.3-70b-instruct": 131072,
"meta-llama/llama-4-maverick": 128000,
"meta-llama/llama-4-scout": 327680,
"meta/muse-glimmer-30b": 131072,
"meta/muse-glimmer-30b:batch": 131072,
"meta/muse-spark-1.1": 1048576,
"meta/muse-spark-1.2": 1048576,
"meta/muse-spark-1.2-contributor": 1048576,
"meta/muse-spark-1.3": 1048576,
"meta/muse-spark-1.3-contributor": 1048576,
"minimax/minimax-m1": 1000000,
"minimax/minimax-m2": 204800,
"minimax/minimax-m2.1": 204800,
"minimax/minimax-m2.5": 200000,
"minimax/minimax-m2.7": 204800,
"minimax/minimax-m2.7:free": 196608,
"minimax/minimax-m3": 524288,
"minimax/minimax-m3:batch": 524288,
"minimax/minimax-m3:free": 1048576,
"mistralai/codestral-2508": 256000,
"mistralai/devstral-2512": 262144,
"mistralai/ministral-14b-2512": 262144,
"mistralai/ministral-3b-2512": 131072,
"mistralai/ministral-8b-2512": 262144,
"mistralai/mistral-large": 128000,
"mistralai/mistral-large-2407": 131072,
"mistralai/mistral-large-2512": 262144,
"mistralai/mistral-medium-3": 131072,
"mistralai/mistral-medium-3-5": 262144,
"mistralai/mistral-medium-3-5:batch": 32768,
"mistralai/mistral-medium-3.1": 131072,
"mistralai/mistral-nemo": 131072,
"mistralai/mistral-saba": 32768,
"mistralai/mistral-small-2603": 262144,
"mistralai/mistral-small-3.2-24b-instruct": 128000,
"mistralai/mixtral-8x22b-instruct": 65536,
"mistralai/voxtral-small-24b-2507": 32768,
"moonshotai/kimi-k2": 131072,
"moonshotai/kimi-k2-0905": 262144,
"moonshotai/kimi-k2-thinking": 262144,
"moonshotai/kimi-k2.5": 262144,
"moonshotai/kimi-k2.6": 262144,
"moonshotai/kimi-k2.7-code": 262144,
"moonshotai/kimi-k3": 1048576,
"moonshotai/kimi-k3:batch": 1048576,
"nex-agi/nex-n2-mini": 262144,
"nex-agi/nex-n2-pro": 262144,
"nvidia/nemotron-3-nano-30b-a3b": 262144,
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free": 256000,
"nvidia/nemotron-3-super-120b-a12b": 262144,
"nvidia/nemotron-3-super-120b-a12b:free": 262144,
"nvidia/nemotron-3-ultra-550b-a55b": 256000,
"nvidia/nemotron-3-ultra-550b-a55b:free": 1000000,
"nvidia/nemotron-3.5-lightning": 262144,
"nvidia/nemotron-3.5-lightning:free": 1000000,
"openai/gpt-3.5-turbo": 16385,
"openai/gpt-3.5-turbo-0613": 4095,
"openai/gpt-3.5-turbo-16k": 16385,
"openai/gpt-3.5-turbo:batch": 16385,
"openai/gpt-4": 8191,
"openai/gpt-4-turbo": 128000,
"openai/gpt-4-turbo-preview": 128000,
"openai/gpt-4-turbo:batch": 128000,
"openai/gpt-4.1": 1047576,
"openai/gpt-4.1-mini": 1047576,
"openai/gpt-4.1-mini:batch": 1047576,
"openai/gpt-4.1-nano": 1047576,
"openai/gpt-4.1-nano:batch": 1047576,
"openai/gpt-4.1:batch": 1047576,
"openai/gpt-4o": 128000,
"openai/gpt-4o-2024-05-13": 128000,
"openai/gpt-4o-2024-08-06": 128000,
"openai/gpt-4o-2024-11-20": 128000,
"openai/gpt-4o-mini": 128000,
"openai/gpt-4o-mini-2024-07-18": 128000,
"openai/gpt-4o-mini:batch": 128000,
"openai/gpt-4o:batch": 128000,
"openai/gpt-5": 400000,
"openai/gpt-5-mini": 400000,
"openai/gpt-5-mini:batch": 400000,
"openai/gpt-5-nano": 400000,
"openai/gpt-5-nano:batch": 400000,
"openai/gpt-5-pro": 400000,
"openai/gpt-5-pro:batch": 400000,
"openai/gpt-5:batch": 400000,
"openai/gpt-5.1": 400000,
"openai/gpt-5.1-codex": 400000,
"openai/gpt-5.1-codex-max": 400000,
"openai/gpt-5.1-codex-mini": 400000,
"openai/gpt-5.1:batch": 400000,
"openai/gpt-5.2": 400000,
"openai/gpt-5.2-chat": 128000,
"openai/gpt-5.2-codex": 400000,
"openai/gpt-5.2-pro": 400000,
"openai/gpt-5.2-pro:batch": 400000,
"openai/gpt-5.2:batch": 400000,
"openai/gpt-5.3-codex": 400000,
"openai/gpt-5.4": 1050000,
"openai/gpt-5.4-mini": 400000,
"openai/gpt-5.4-mini:batch": 400000,
"openai/gpt-5.4-nano": 400000,
"openai/gpt-5.4-nano:batch": 400000,
"openai/gpt-5.4-pro": 1050000,
"openai/gpt-5.4-pro:batch": 1050000,
"openai/gpt-5.4:batch": 1050000,
"openai/gpt-5.5": 1050000,
"openai/gpt-5.5-pro": 1050000,
"openai/gpt-5.5-pro:batch": 1050000,
"openai/gpt-5.5:batch": 1050000,
"openai/gpt-5.6-luna": 1050000,
"openai/gpt-5.6-luna-pro": 1050000,
"openai/gpt-5.6-luna-pro:batch": 1050000,
"openai/gpt-5.6-luna:batch": 1050000,
"openai/gpt-5.6-sol": 1050000,
"openai/gpt-5.6-sol-pro": 1050000,
"openai/gpt-5.6-sol-pro:batch": 1050000,
"openai/gpt-5.6-sol:batch": 1050000,
"openai/gpt-5.6-terra": 1050000,
"openai/gpt-5.6-terra-pro": 1050000,
"openai/gpt-5.6-terra-pro:batch": 1050000,
"openai/gpt-5.6-terra:batch": 1050000,
"openai/gpt-6-astra": 1050000,
"openai/gpt-6-astra-pro": 1050000,
"openai/gpt-6-astra-pro:batch": 1050000,
"openai/gpt-6-astra:batch": 1050000,
"openai/gpt-audio": 128000,
"openai/gpt-audio-mini": 128000,
"openai/gpt-chat-latest": 400000,
"openai/gpt-oss-120b": 131072,
"openai/gpt-oss-120b:batch": 131072,
"openai/gpt-oss-20b": 131072,
"openai/gpt-oss-safeguard-20b": 131072,
"openai/o1": 200000,
"openai/o3": 200000,
"openai/o3-mini": 200000,
"openai/o3-mini-high": 200000,
"openai/o3-mini:batch": 200000,
"openai/o3-pro": 200000,
"openai/o3:batch": 200000,
"openai/o4-mini": 200000,
"openai/o4-mini-high": 200000,
"openai/o4-mini:batch": 200000,
"openrouter/auto": 2000000,
"openrouter/auto-beta": 2000000,
"openrouter/free": 200000,
"openrouter/fusion": 1000000,
"poolside/laguna-s-2.1": 1048576,
"poolside/laguna-s-2.1:free": 262144,
"poolside/laguna-xs-2.1": 262144,
"poolside/laguna-xs-2.1:free": 262144,
"qwen/qwen-2.5-72b-instruct": 32768,
"qwen/qwen-2.5-7b-instruct": 32768,
"qwen/qwen-plus": 1000000,
"qwen/qwen-plus-2025-07-28": 1000000,
"qwen/qwen3-14b": 40960,
"qwen/qwen3-235b-a22b": 131072,
"qwen/qwen3-235b-a22b-2507": 262144,
"qwen/qwen3-235b-a22b-thinking-2507": 131072,
"qwen/qwen3-30b-a3b": 40960,
"qwen/qwen3-30b-a3b-instruct-2507": 128000,
"qwen/qwen3-30b-a3b-thinking-2507": 81920,
"qwen/qwen3-32b": 40960,
"qwen/qwen3-8b": 131072,
"qwen/qwen3-coder": 262144,
"qwen/qwen3-coder-30b-a3b-instruct": 262144,
"qwen/qwen3-coder-flash": 1000000,
"qwen/qwen3-coder-next": 262144,
"qwen/qwen3-coder-plus": 1000000,
"qwen/qwen3-max": 262144,
"qwen/qwen3-max-thinking": 262144,
"qwen/qwen3-next-80b-a3b-instruct": 262144,
"qwen/qwen3-next-80b-a3b-thinking": 131072,
"qwen/qwen3-vl-235b-a22b-instruct": 131072,
"qwen/qwen3-vl-235b-a22b-thinking": 131072,
"qwen/qwen3-vl-30b-a3b-instruct": 262144,
"qwen/qwen3-vl-30b-a3b-thinking": 131072,
"qwen/qwen3-vl-32b-instruct": 131072,
"qwen/qwen3-vl-8b-instruct": 131072,
"qwen/qwen3-vl-8b-thinking": 131072,
"qwen/qwen3.5-122b-a10b": 262144,
"qwen/qwen3.5-27b": 262144,
"qwen/qwen3.5-35b-a3b": 262144,
"qwen/qwen3.5-397b-a17b": 262144,
"qwen/qwen3.5-9b": 262144,
"qwen/qwen3.5-9b:batch": 262144,
"qwen/qwen3.5-flash-02-23": 1000000,
"qwen/qwen3.5-plus-02-15": 1000000,
"qwen/qwen3.5-plus-20260420": 1000000,
"qwen/qwen3.6-27b": 262144,
"qwen/qwen3.6-35b-a3b": 262144,
"qwen/qwen3.6-flash": 1000000,
"qwen/qwen3.6-max-preview": 262144,
"qwen/qwen3.6-plus": 1000000,
"qwen/qwen3.7-flash": 1000000,
"qwen/qwen3.7-max": 1000000,
"qwen/qwen3.7-plus": 1000000,
"qwen/qwen3.8-2.4t-a95b": 1000000,
"qwen/qwen3.8-2.4t-a95b:batch": 1010000,
"qwen/qwen3.8-27b": 1000000,
"qwen/qwen3.8-flash": 1000000,
"qwen/qwen3.8-max-0902": 1000000,
"rekaai/reka-edge": 16384,
"relace/relace-search": 256000,
"sakana/fugu-ultra": 1000000,
"sakana/sakana-namazu": 262144,
"sao10k/l3.1-euryale-70b": 131072,
"stepfun/step-3.5-flash": 262144,
"stepfun/step-3.7-flash": 256000,
"tencent/hy3": 262144,
"tencent/hy3-preview": 262144,
"tencent/hy4-preview": 1048576,
"thedrummer/unslopnemo-12b": 32768,
"thinkingmachines/inkling": 524288,
"thinkingmachines/inkling-small": 524288,
"thinkingmachines/inkling-small:batch": 524288,
"thinkingmachines/inkling-small:free": 1048576,
"thinkingmachines/inkling:batch": 524288,
"thinkingmachines/inkling:free": 1048576,
"upstage/solar-pro-3": 131072,
"upstage/solar-pro4": 524288,
"x-ai/grok-4.20": 2000000,
"x-ai/grok-4.3": 1000000,
"x-ai/grok-4.3:batch": 1000000,
"x-ai/grok-4.5": 500000,
"x-ai/grok-4.6": 500000,
"x-ai/grok-build-0.1": 256000,
"xiaomi/mimo-v2.5": 1048576,
"xiaomi/mimo-v2.5-pro": 1048576,
"z-ai/glm-4.5": 131072,
"z-ai/glm-4.5-air": 131072,
"z-ai/glm-4.5v": 65536,
"z-ai/glm-4.6": 204800,
"z-ai/glm-4.6v": 131072,
"z-ai/glm-4.7": 202752,
"z-ai/glm-4.7-flash": 202752,
"z-ai/glm-5": 198000,
"z-ai/glm-5-turbo": 202752,
"z-ai/glm-5.1": 200000,
"z-ai/glm-5.2": 1048576,
"z-ai/glm-5.2:free": 256000,
"z-ai/glm-5.3": 1048576,
"z-ai/glm-5.3-flash": 1048576,
"z-ai/glm-5.3-flash:batch": 1048575,
"z-ai/glm-5v-turbo": 202752,
};
// ─── Pure helpers (folds + cost inference) ───────────────────────────────────
+11 -4
View File
@@ -1,9 +1,10 @@
import type { UIMessage } from "ai";
import type { Usage } from "@earendil-works/pi-ai";
import { createProberAgent, createTeacherAgent, type TeachingAgent } from "../agent/teacher-agent.ts";
import { piEngineFor } from "../agent/models.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import { MODELS } from "../../config.ts";
import type { ModelRole } from "../../config.ts";
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { recordToPiMessages } from "./pi/record-adapter.ts";
import { createPiTurnResponse } from "./pi/ui-stream.ts";
@@ -147,7 +148,13 @@ export async function handleChatRequest(
// Both share this whole composition (write-ahead persist, liveness,
// steers, usage, extraction) — only the agent differs.
const teaching = outgoing.some((m) => m.parts.some((p) => p.type === "tool-probe_done"));
const modelId = teaching ? MODELS.teacher.model : MODELS.prober.model;
const role: ModelRole = teaching ? "teacher" : "prober";
// The chat's model-control settings (prober/teacher only — one controller
// for the conversation agents; piEngineFor falls back to config on absent
// fields and clamps the thinking level to the model's supported list).
const settings = stores.chatStore.getChat(chatId)?.settings;
const engine = piEngineFor(role, settings);
const modelId = engine.model.id;
// 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
// transcript — the factory mirrors that seam exactly.
@@ -161,7 +168,7 @@ export async function handleChatRequest(
jobStore: stores.jobStore,
spawn: (job, input) => void jobRunner.runJob(job.id, input),
},
{ messages: recordToPiMessages(outgoing), onUsage: collectUsage },
{ engine, messages: recordToPiMessages(outgoing), onUsage: collectUsage },
)
: createProberAgent(
{
@@ -169,7 +176,7 @@ export async function handleChatRequest(
chatStore: stores.chatStore,
deliver: (steer) => void deliverJobResult(steer),
},
{ messages: recordToPiMessages(outgoing), onUsage: collectUsage },
{ engine, messages: recordToPiMessages(outgoing), onUsage: collectUsage },
);
// A test-injected agent gets no usage collector (tests assert the
+1
View File
@@ -11,3 +11,4 @@ export * from "./jobs.ts";
export * from "./stores.ts";
export * from "./turn-liveness.ts";
export * from "./observability.ts";
export * from "./model-catalog.ts";
+108
View File
@@ -0,0 +1,108 @@
/**
* The model catalog's server surface (model-control wave, 2026-09-12):
* pi-ai's shipped OpenRouter catalog projected to client-safe DTOs for the
* picker (GET /api/models), plus the validation gate for per-chat settings
* (PUT /api/chats/[id]/settings).
*
* pi-ai owns ALL the data: the catalog (id/name/cost/context/modality/
* thinkingLevelMap) and the per-model thinking truth
* (getSupportedThinkingLevels / clampThinkingLevel). This module only
* projects and validates — no second registry to drift out of sync.
*
* Multi-provider future: `getModels()` is called per configured provider,
* so a second provider (pi-ai ships ~30) appears in the picker by adding
* it to piModels() in src/agent/models.ts — nothing here changes.
*/
import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { MODELS } from "../../config.ts";
import { piModels } from "../agent/models.ts";
import type { ChatSettings } from "../core/contracts/chat.ts";
import type {
CatalogDefaults,
ModelCatalogEntry,
ModelCatalogResponse,
} from "../core/contracts/models.ts";
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
export type ModelDto = ModelCatalogEntry;
/** The conversation agents' config pin — what a settings-less chat runs. */
export function catalogDefaults(): CatalogDefaults {
return {
model: { provider: "openrouter", id: MODELS.prober.model },
thinkingLevel: MODELS.prober.effort as ThinkingLevel,
};
}
/** Project one catalog model to the client DTO. */
export function toModelDto(model: Model<Api>): ModelDto {
return {
id: model.id,
name: model.name,
provider: model.provider,
reasoning: model.reasoning,
input: [...model.input],
supportedLevels: getSupportedThinkingLevels(model),
cost: {
input: model.cost.input,
output: model.cost.output,
cacheRead: model.cost.cacheRead,
cacheWrite: model.cost.cacheWrite,
},
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
};
}
/** The full picker payload: every model of every configured provider
* (sorted by provider then id — stable, diff-friendly) + the config
* defaults a settings-less chat runs. */
export function modelCatalog(): ModelCatalogResponse {
const models = piModels()
.getProviders()
.flatMap((p) => piModels().getModels(p.id))
.map(toModelDto)
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
return { models, defaults: catalogDefaults() };
}
/** One validated outcome for a settings write. */
export type SettingsValidation =
| { ok: true }
| { ok: false; error: string; supported?: ThinkingLevel[] };
/** Validate a picker write against the catalog (the settings route's gate).
* `settings` is replace-whole: absent fields mean "config default".
* - model: provider must be configured, id must exist in its catalog.
* - thinkingLevel: must be in the resolved model's supported list —
* the model the picker writes against (settings.model, else config's
* prober/teacher pin; one controller, so the two specs share it). */
export function validateChatSettings(settings: ChatSettings): SettingsValidation {
const providers = piModels().getProviders().map((p) => p.id);
if (settings.model) {
const { provider, id } = settings.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 (settings.thinkingLevel !== undefined) {
const provider = settings.model?.provider ?? "openrouter";
const id = settings.model?.id ?? MODELS.prober.model;
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(settings.thinkingLevel)) {
return {
ok: false,
error: `${id} does not support thinking level "${settings.thinkingLevel}"`,
supported,
};
}
}
return { ok: true };
}
+13 -1
View File
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { PATHS } from "../../config.ts";
import type { ChatStore } from "../core/ports.ts";
import type { ChatRecord, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
import type { UIMessage } from "ai";
import { findPendingToolCall } from "../core/pending.ts";
import { phaseAt3 } from "../core/phases.ts";
@@ -105,6 +105,17 @@ function appendExtractionUsage(id: string, entry: ExtractionRunUsage): void {
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
}
/** Persist the per-chat model/thinking settings — replace-whole (the
* picker sends the complete object; absent = config defaults). */
function saveSettings(id: string, settings: ChatSettings): void {
const record = getChat(id);
if (!record) throw new Error(`Chat not found: ${id}`);
// Like saveTree: a settings tweak is not conversation activity, so it
// deliberately does NOT bump updatedAt (sidebar order must not churn).
record.settings = settings;
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
}
/** Persist the session plan tree (plan tools call this in execute). */
function saveTree(id: string, tree: PlanTree): void {
const record = getChat(id);
@@ -121,6 +132,7 @@ export const chatStore: ChatStore = {
listChats,
saveMessages,
saveTree,
saveSettings,
appendTurnUsage,
appendExtractionUsage,
};
+14
View File
@@ -422,6 +422,20 @@ describe("chat store", () => {
assert.equal(loaded.messages.length, 1);
assert.equal(loaded.title, "Store test");
});
it("persist the per-chat model settings (replace-whole, no updatedAt churn)", async () => {
const chat = chatStore.createChat("Settings test");
createdChats.push(chat.id);
const before = chatStore.getChat(chat.id)!.updatedAt;
chatStore.saveSettings(chat.id, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "high" });
const saved = chatStore.getChat(chat.id)!;
assert.deepEqual(saved.settings, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "high" });
assert.equal(saved.updatedAt, before, "a picker write is not conversation activity");
// replace-whole: a settings-less write clears the override (an empty
// object persists — every field absent = config defaults)
chatStore.saveSettings(chat.id, {});
assert.deepEqual(chatStore.getChat(chat.id)!.settings, {});
});
});
describe("mid-turn navigation (turn liveness + write-ahead)", () => {
+42
View File
@@ -0,0 +1,42 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { fuzzyScore } from "../src/core/fuzzy.ts";
// The picker's search ranking (pi's /model pattern): tokens must each match
// some field; exact beats prefix beats buried substring; whole-query
// contiguity in one field is a bonus.
describe("fuzzyScore", () => {
const fields = (id: string, name: string) => ["openrouter", id, name];
it("matches across fields case-insensitively", () => {
assert.ok(fuzzyScore("GLM", ...fields("z-ai/glm-5.3-flash", "ZAI: GLM 5.3 Flash")) !== null);
assert.ok(fuzzyScore("flash", ...fields("z-ai/glm-5.3-flash", "ZAI: GLM 5.3 Flash")) !== null);
});
it("multi-token queries need every token to match somewhere", () => {
assert.ok(fuzzyScore("glm flash", ...fields("z-ai/glm-5.3-flash", "ZAI: GLM 5.3 Flash")) !== null);
assert.equal(fuzzyScore("glm qwen", ...fields("z-ai/glm-5.3-flash", "ZAI: GLM 5.3 Flash")), null);
});
it("ranks exact field equality above prefix above buried substring", () => {
const exact = fuzzyScore("flash", "openrouter", "flash", "other"); // field === token
const prefix = fuzzyScore("flash", "openrouter", "flash-2", "other"); // field startsWith
const buried = fuzzyScore("flash", "openrouter", "z-ai/glm-5.3-flash", "other");
assert.ok(exact !== null && prefix !== null && buried !== null);
assert.ok(exact > prefix && prefix > buried, `${exact} > ${prefix} > ${buried}`);
});
it("rewards whole-query contiguity in a single field", () => {
const split = fuzzyScore("glm flash", ...fields("z-ai/glm-5.3-flash", "x")); // two fields, no contiguity
const joined = fuzzyScore("glm 5.3", ...fields("z-ai/glm-5.3-flash", "x")); // contiguous in the id
assert.ok(split !== null && joined !== null);
// same per-token points; joined earns the +50 contiguity bonus
assert.ok(joined > split, `${joined} > ${split}`);
});
it("empty query matches everything at score 0", () => {
assert.equal(fuzzyScore("", "anything"), 0);
assert.equal(fuzzyScore(" ", "anything"), 0);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { modelCatalog, validateChatSettings, catalogDefaults, toModelDto } from "../src/server/model-catalog.ts";
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { MODELS } from "../config.ts";
import { piEngineFor } from "../src/agent/models.ts";
// The model-control wave (2026-09-12): pi-ai owns the registry — these pins
// hold our projection + validation honest against the shipped catalog, and
// pin pi's per-model thinking truth so a pi-ai upgrade can't silently change
// what we send.
describe("the model catalog projection", () => {
it("serves pi-ai's OpenRouter catalog with the picker's DTO shape", () => {
const { models, defaults } = modelCatalog();
// 0.85.1 ships 366 OpenRouter models — the exact count is pi's, but
// the shape and the config models' presence are ours to pin.
assert.ok(models.length > 300, `expected a full catalog, got ${models.length}`);
for (const m of models.slice(0, 25)) {
assert.equal(typeof m.id, "string");
assert.equal(typeof m.name, "string");
assert.ok(Array.isArray(m.supportedLevels));
assert.ok(typeof m.cost.input === "number" && typeof m.cost.output === "number");
assert.ok(m.contextWindow > 0);
}
const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash");
assert.ok(glm, "glm-5.3-flash in catalog");
assert.deepEqual(glm!.supportedLevels, ["low", "high", "max"]);
assert.deepEqual(glm!.input, ["text", "image"]);
// pi's current glm price (our old table said 0.15/0.5 — stale)
assert.deepEqual(glm!.cost, { input: 0.075, output: 0.25, cacheRead: 0.015, cacheWrite: 0 });
});
it("defaults mirror the conversation agents' config pin", () => {
const d = catalogDefaults();
assert.deepEqual(d, { model: { provider: "openrouter", id: MODELS.prober.model }, thinkingLevel: MODELS.prober.effort });
assert.equal(d.model.id, MODELS.teacher.model, "one controller: prober and teacher share the pin");
});
it("toModelDto projects the raw catalog model verbatim", () => {
const raw = piEngineFor("prober").model; // the catalog's glm entry
const dto = toModelDto(raw);
assert.equal(dto.id, raw.id);
assert.equal(dto.name, raw.name);
assert.deepEqual(dto.supportedLevels, getSupportedThinkingLevels(raw));
assert.equal(dto.cost.input, raw.cost.input);
assert.equal(dto.contextWindow, raw.contextWindow);
});
});
describe("the settings validation gate", () => {
it("accepts a supported level and rejects an unsupported one with the list", () => {
const ok = validateChatSettings({ model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "max" });
assert.deepEqual(ok, { ok: true });
const bad = validateChatSettings({ model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "minimal" });
assert.equal(bad.ok, false);
if (!bad.ok) {
assert.match(bad.error, /does not support thinking level/);
assert.deepEqual(bad.supported, ["low", "high", "max"]);
}
});
it("resolves a bare thinking level against the config default model", () => {
assert.deepEqual(validateChatSettings({ thinkingLevel: "high" }), { ok: true });
// "minimal" is unsupported on config's glm — rejected even with no model set
assert.equal(validateChatSettings({ thinkingLevel: "minimal" }).ok, false);
});
it("rejects unknown providers and models", () => {
assert.equal(validateChatSettings({ model: { provider: "nope", id: "x/y" } }).ok, false);
assert.equal(validateChatSettings({ model: { provider: "openrouter", id: "nope/nope" } }).ok, false);
});
it("accepts empty settings (the clear-overrides write)", () => {
assert.deepEqual(validateChatSettings({}), { ok: true });
});
});
describe("pi thinking truth (pinned against pi-ai upgrades)", () => {
it("glm-5.3-flash: low|high|max; anything else clamps to the nearest", () => {
const glm = piEngineFor("prober").model;
assert.deepEqual(getSupportedThinkingLevels(glm), ["low", "high", "max"]);
assert.equal(clampThinkingLevel(glm, "minimal"), "low");
assert.equal(clampThinkingLevel(glm, "off"), "low", "GLM cannot disable thinking (map: off → null)");
assert.equal(clampThinkingLevel(glm, "xhigh"), "max");
assert.equal(clampThinkingLevel(glm, "high"), "high");
});
it("the engine factory honors settings, falls back to config, clamps", () => {
// override both fields
const over = piEngineFor("teacher", { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" });
assert.equal(over.model.id, "qwen/qwen3.8-flash");
assert.equal(over.level, "high");
// no settings → config pin
const pinned = piEngineFor("prober");
assert.equal(pinned.model.id, MODELS.prober.model);
assert.equal(pinned.level, MODELS.prober.effort);
// settings level unsupported by the settings model → clamped honestly
const clamped = piEngineFor("teacher", { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "minimal" });
assert.equal(clamped.level, "low");
// bare level resolves against the config default model
assert.equal(piEngineFor("prober", { thinkingLevel: "max" }).level, "max");
// unknown model → honest throw, never a silent fallback
assert.throws(() => piEngineFor("teacher", { model: { provider: "openrouter", id: "nope/nope" } }), /catalog has no model/);
});
});
+6 -5
View File
@@ -57,8 +57,9 @@ describe("activity dock pure layer", () => {
},
});
const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
// (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014
assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015);
// (10000-5000)/1e6*0.075 + 5000/1e6*0.015 + 1000/1e6*0.25 = 0.0007
// (pi-ai catalog prices)
assert.ok(blob.cost !== null && blob.cost > 0.0006 && blob.cost < 0.0008);
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
assert.equal(absent.cost, null);
});
@@ -78,7 +79,7 @@ describe("activity dock pure layer", () => {
});
const blob = toBlobs({ jobs: [withLast], extractions: [] })[0]!;
assert.equal(blob.context?.used, 92_000);
assert.equal(blob.context?.limit, 1310720);
assert.equal(blob.context?.limit, 1048576);
// No lastStep yet (capture pending) → "—", even though usage exists.
const stripped = toBlobs({ jobs: [job({ usage: { ...base } })], extractions: [] })[0]!;
assert.equal(stripped.context, null);
@@ -167,7 +168,7 @@ describe("activity dock render", () => {
}}
/>,
);
// two identical priced jobs → 2 × $0.002 = $0.0040 total
assert.match(html, /activity-total-cost[^>]*>\$0\.0040</);
// two identical priced jobs → 2 × $0.001 = $0.0020 total (pi prices)
assert.match(html, /activity-total-cost[^>]*>\$0\.0020</);
});
});
+25 -13
View File
@@ -3,11 +3,11 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { StatsBar } from "../components/stats-bar.tsx";
import { prettyModel } from "../components/usage-format.ts";
import type { TurnUsage } from "../src/core/contracts.ts";
import type { ChatSettings, TurnUsage } from "../src/core/contracts.ts";
// The stats bar: folds only (no cost math in the component), the "—"
// doctrine for absent data, honest cost (never a fake $0), and the
// model/thinking placeholder chip for the coming control.
// doctrine for absent data, honest cost (never a fake $0), and the model +
// thinking control (components/model-picker.tsx — pi-ai catalog backed).
function step(inputTokens: number, outputTokens: number, over: Record<string, number | undefined> = {}) {
return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined, ...over };
@@ -24,28 +24,40 @@ function turn(over: Partial<TurnUsage> = {}): TurnUsage {
};
}
const SETTINGS_STUB = (_s: ChatSettings) => {};
describe("stats bar", () => {
it("renders — everywhere when the record has no turns yet (doctrine)", () => {
const html = renderToStaticMarkup(<StatsBar turns={[]} />);
const html = renderToStaticMarkup(
<StatsBar turns={[]} chatId="chat_x" onSettingsSaved={SETTINGS_STUB} />,
);
assert.match(html, /data-testid="stats-bar"/);
assert.match(html, /data-testid="stats-context">—</);
assert.match(html, /data-testid="stats-cost">—</);
assert.match(html, /data-testid="stats-model">—</);
// no turns, no settings, catalog not loaded → both control labels "—"
assert.match(html, /data-testid="stats-model"[^>]*><span>—<\/span>/);
assert.match(html, /Thinking —</);
});
it("folds context + cost from the turns log (core/prices, no local math)", () => {
const html = renderToStaticMarkup(<StatsBar turns={[turn(), turn()]} />);
// last turn's last call: 10k in + 1k out = 11k; limit 1,310,720 → "1.3M"
assert.match(html, /data-testid="stats-context">11k \/ 1\.3M</);
// each turn: 10k fresh input ×0.15 + 1k output ×0.5 = $0.002 → two = $0.0040
assert.match(html, /data-testid="stats-cost">\$0\.0040</);
// model chip: pretty name from the last turn's model id
assert.match(html, /data-testid="stats-model">Glm 5\.3 Flash</);
const html = renderToStaticMarkup(
<StatsBar turns={[turn(), turn()]} chatId="chat_x" onSettingsSaved={SETTINGS_STUB} />,
);
// last turn's last call: 10k in + 1k out = 11k; pi catalog limit
// 1_048_576 → "1M" (tables regenerated from pi-ai's catalog)
assert.match(html, /data-testid="stats-context">11k \/ 1M</);
// pi catalog price 0.075/0.25 per 1M: each turn 10k×0.075 + 1k×0.25
// = $0.001 → two = $0.0020
assert.match(html, /data-testid="stats-cost">\$0\.0020</);
// model button label falls back to the last turn's actual model
// (the catalog + settings are what drive it once loaded)
assert.match(html, /data-testid="stats-model"[^>]*><span>Glm 5\.3 Flash<\/span>/);
});
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
const html = renderToStaticMarkup(<StatsBar turns={[turn({ model: "nope/unknown" })]} />);
const html = renderToStaticMarkup(
<StatsBar turns={[turn({ model: "nope/unknown" })]} chatId="chat_x" onSettingsSaved={SETTINGS_STUB} />,
);
assert.match(html, /data-testid="stats-cost">—</);
// unknown model also kills the context limit
assert.match(html, /data-testid="stats-context">—</);
+4 -3
View File
@@ -41,8 +41,9 @@ test("sumSteps folds steps; absent stays absent until a value appears", () => {
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");
assert.ok(cost !== null);
// fresh 90k @ $0.15/M + cached 10k @ $0.03/M + output 20k @ $0.50/M
assert.ok(Math.abs(cost! - (0.0135 + 0.0003 + 0.01)) < 1e-9);
// fresh 90k @ $0.075/M + cached 10k @ $0.015/M + output 20k @ $0.25/M
// (pi-ai catalog prices — regenerated tables, scripts/sync-pricing)
assert.ok(Math.abs(cost! - (0.00675 + 0.00015 + 0.005)) < 1e-9);
});
test("callCost: cache writes bill at the cacheWrite rate", () => {
@@ -86,7 +87,7 @@ test("contextWindow: last call's footprint against the model's limit", () => {
assert.ok(cw);
assert.equal(cw.model, "z-ai/glm-5.3-flash");
assert.equal(cw.usedTokens, 100_000);
assert.equal(cw.limit, 1_310_720);
assert.equal(cw.limit, 1_048_576);
assert.equal(contextWindow({ turns: [] }), null);
});