diff --git a/app/api/chats/[id]/settings/route.ts b/app/api/chats/[id]/settings/route.ts new file mode 100644 index 0000000..2d8fc9f --- /dev/null +++ b/app/api/chats/[id]/settings/route.ts @@ -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 }); +} diff --git a/app/api/models/route.ts b/app/api/models/route.ts new file mode 100644 index 0000000..90e28b4 --- /dev/null +++ b/app/api/models/route.ts @@ -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()); +} diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index 627dc7a..71f4af0 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -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} /> ); } diff --git a/components/chat.tsx b/components/chat.tsx index dee3282..2f55fd9 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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({ @@ -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([]); + // 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(initialSettings); const syncTurns = useCallback(async () => { try { const res = await fetch(`/api/chats/${chatId}`); @@ -582,6 +589,9 @@ export function Chat({ ; /** 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. */} - + diff --git a/components/model-picker.tsx b/components/model-picker.tsx new file mode 100644 index 0000000..9f64b4e --- /dev/null +++ b/components/model-picker.tsx @@ -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 | undefined; + +function fetchCatalog(): Promise { + catalogInflight ??= fetch("/api/models") + .then((r) => { + if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); + return r.json() as Promise; + }) + .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 = { + 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(null); + const searchRef = useRef(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 ( +
+ + · + + + {open === "model" && ( +
+ 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 &&
{error}
} + {!catalog && ( +
Loading models…
+ )} + {catalog && results.length === 0 && ( +
No models match.
+ )} +
    + {results.map((m) => ( +
  • + +
  • + ))} +
+
+ Applies to the next turn · background agents stay config-pinned +
+
+ )} + + {open === "thinking" && ( +
+ {!catalog &&
Loading…
} + {catalog && levels.length === 0 && ( +
+ {effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first."} +
+ )} + {levels.map((level) => ( + + ))} + {error &&
{error}
} +
+ Levels this model actually supports +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 673a1d8..e93400e 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -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[] }) { · {costLabel} - {/* Model + thinking level — a placeholder for the coming control - (contract changes land with it); display-only today. */} -
- {lastModel ? prettyModel(lastModel) : "—"} - · - - Thinking — - -
+ {/* 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. */} + {open && (
; export type ModelRole = keyof typeof MODELS; diff --git a/scripts/sync-pricing.ts b/scripts/sync-pricing.ts index 70c857f..68ef9e1 100644 --- a/scripts/sync-pricing.ts +++ b/scripts/sync-pricing.ts @@ -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; -} - -/** 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 = { - "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 { - 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 — every OpenRouter id is unique across the two maps. + const catalog = JSON.parse(fs.readFileSync(CATALOG, "utf-8")) as Record< + string, + Record + >; + const byId = new Map(); + 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(); - 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 = { -${[...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 = { -${[...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(); \ No newline at end of file diff --git a/src/agent/models.ts b/src/agent/models.ts index 6cf3815..712dbc2 100644 --- a/src/agent/models.ts +++ b/src/agent/models.ts @@ -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; 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 | 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, diff --git a/src/core/contracts.ts b/src/core/contracts.ts index 7e81769..13d7e8e 100644 --- a/src/core/contracts.ts +++ b/src/core/contracts.ts @@ -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"; diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index 8f8114e..683bd19 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -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 diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts new file mode 100644 index 0000000..60906a6 --- /dev/null +++ b/src/core/contracts/models.ts @@ -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; +} \ No newline at end of file diff --git a/src/core/fuzzy.ts b/src/core/fuzzy.ts new file mode 100644 index 0000000..9c46047 --- /dev/null +++ b/src/core/fuzzy.ts @@ -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; +} \ No newline at end of file diff --git a/src/core/index.ts b/src/core/index.ts index 266b821..84d1676 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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"; diff --git a/src/core/ports.ts b/src/core/ports.ts index c3b347b..6343a2e 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -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) ────────────── diff --git a/src/core/prices.ts b/src/core/prices.ts index 4878a84..c2e583c 100644 --- a/src/core/prices.ts +++ b/src/core/prices.ts @@ -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 = { - "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 = { - "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) ─────────────────────────────────── diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index a0dcd15..a4a3436 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -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 diff --git a/src/server/index.ts b/src/server/index.ts index bf2aa4a..bf5b3b6 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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"; diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts new file mode 100644 index 0000000..5a552e2 --- /dev/null +++ b/src/server/model-catalog.ts @@ -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): 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 }; +} \ No newline at end of file diff --git a/src/store/chats.ts b/src/store/chats.ts index 4b12226..639f770 100644 --- a/src/store/chats.ts +++ b/src/store/chats.ts @@ -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, }; diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index 25824c1..e4085fc 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -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)", () => { diff --git a/tests/fuzzy.test.ts b/tests/fuzzy.test.ts new file mode 100644 index 0000000..cdedb35 --- /dev/null +++ b/tests/fuzzy.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts new file mode 100644 index 0000000..367628c --- /dev/null +++ b/tests/model-catalog.test.ts @@ -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/); + }); +}); \ No newline at end of file diff --git a/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx index 09d3c37..6ef48eb 100644 --- a/tests/render-activity-dock.test.tsx +++ b/tests/render-activity-dock.test.tsx @@ -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]*>\$0\.0020 = {}) { return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined, ...over }; @@ -24,28 +24,40 @@ function turn(over: Partial = {}): TurnUsage { }; } +const SETTINGS_STUB = (_s: ChatSettings) => {}; + describe("stats bar", () => { it("renders — everywhere when the record has no turns yet (doctrine)", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup( + , + ); assert.match(html, /data-testid="stats-bar"/); assert.match(html, /data-testid="stats-context">———]*>—<\/span>/); assert.match(html, /Thinking — { - const html = renderToStaticMarkup(); - // 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\$0\.0040Glm 5\.3 Flash, + ); + // 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\$0\.0020]*>Glm 5\.3 Flash<\/span>/); }); it("shows — for cost when every turn is unpriceable, never a fake $0", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup( + , + ); assert.match(html, /data-testid="stats-cost">—— { 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); });